typespec_from_serializers 0.1.1 → 0.2.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.
@@ -0,0 +1,461 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSpecFromSerializers
4
+ # Public: Sorbet integration for type inference.
5
+ #
6
+ # This module provides optional integration with Sorbet's runtime type system
7
+ # and RBI files. It can extract type information from:
8
+ # 1. Runtime signatures (T::Utils.signature_for_method)
9
+ # 2. RBI files generated by Tapioca (in sorbet/rbi/ directory)
10
+ #
11
+ # The module gracefully degrades when sorbet-runtime is not present, making
12
+ # Sorbet integration completely optional with zero configuration required.
13
+ module Sorbet
14
+ module_function
15
+
16
+ # Public: Check if Sorbet runtime is available.
17
+ #
18
+ # Returns true if sorbet-runtime gem is loaded and the necessary APIs
19
+ # are available for type extraction.
20
+ #
21
+ # Returns Boolean
22
+ def available?
23
+ !!(defined?(T::Utils) && T::Utils.respond_to?(:signature_for_method))
24
+ end
25
+
26
+ # Public: Get the RBI directory path.
27
+ #
28
+ # Returns Pathname or nil if directory doesn't exist or can't be accessed
29
+ def rbi_path
30
+ return @rbi_path if defined?(@rbi_path)
31
+
32
+ @rbi_path = begin
33
+ TypeSpecFromSerializers.rbi_dir.then { |path| path if path.exist? && path.directory? }
34
+ rescue
35
+ nil
36
+ end
37
+ end
38
+
39
+ # Public: Check if RBI files are available for parsing.
40
+ #
41
+ # Returns true if sorbet/rbi/ directory exists (typical Tapioca setup).
42
+ #
43
+ # Returns Boolean
44
+ def rbi_available?
45
+ !rbi_path.nil?
46
+ end
47
+
48
+ # Public: Extract type information for a method from its Sorbet signature.
49
+ #
50
+ # Tries multiple sources in order:
51
+ # 1. Runtime reflection (T::Utils.signature_for_method)
52
+ # 2. RBI files (if available)
53
+ #
54
+ # klass - The Class containing the method
55
+ # method_name - The Symbol or String name of the method
56
+ #
57
+ # Returns a Hash with keys:
58
+ # :typespec_type - Symbol or String representing the TypeSpec type
59
+ # :nilable - Boolean indicating if type is optional
60
+ # :array - Boolean indicating if type is an array
61
+ # Returns nil if no signature found
62
+ #
63
+ # Examples
64
+ #
65
+ # class MySerializer
66
+ # extend T::Sig
67
+ #
68
+ # sig { returns(String) }
69
+ # def name
70
+ # "example"
71
+ # end
72
+ # end
73
+ #
74
+ # extract_type_for(MySerializer, :name)
75
+ # # => { typespec_type: :string, nilable: false, array: false }
76
+ #
77
+ def extract_type_for(klass, method_name)
78
+ # Try TypeDSL first (if available), then runtime reflection, then RBI files
79
+ extract_from_type_dsl(klass, method_name) ||
80
+ (available? ? extract_from_runtime(klass, method_name) : nil) ||
81
+ (rbi_available? ? extract_from_rbi(klass, method_name) : nil)
82
+ end
83
+
84
+ class << self
85
+ private
86
+
87
+ # Internal: Extract type from TypeDSL declaration.
88
+ def extract_from_type_dsl(klass, method_name)
89
+ return nil unless klass.respond_to?(:type_for_method)
90
+
91
+ type_annotation = klass.type_for_method(method_name)
92
+ return nil unless type_annotation
93
+
94
+ # Handle plain Ruby classes
95
+ if type_annotation.is_a?(Class)
96
+ type_name = type_annotation.name
97
+ mapped_type = config_type_mapping[type_name]
98
+ result = {
99
+ typespec_type: mapped_type || type_name,
100
+ nilable: false,
101
+ array: false,
102
+ type_class: type_annotation,
103
+ }
104
+ return result
105
+ end
106
+
107
+ # Handle Sorbet type annotations
108
+ sorbet_type_to_typespec(type_annotation)
109
+ rescue => e
110
+ warn "TypeSpec: Failed to extract type for #{klass}##{method_name}: #{e.class} - #{e.message}"
111
+ nil
112
+ end
113
+
114
+ # Internal: Get type mapping from config.
115
+ def config_type_mapping
116
+ TypeSpecFromSerializers.config.sorbet_to_typespec_type_mapping
117
+ end
118
+
119
+ # Internal: Extract type from runtime reflection.
120
+ def extract_from_runtime(klass, method_name)
121
+ method = klass.instance_method(method_name.to_sym)
122
+ extract_from_method(method)
123
+ rescue NameError
124
+ # Method doesn't exist on this class
125
+ nil
126
+ end
127
+
128
+ # Internal: Extract type from RBI files.
129
+ def extract_from_rbi(klass, method_name)
130
+ class_name = klass.name
131
+ return nil unless class_name
132
+
133
+ # Look up in unified RBI index
134
+ signature = rbi_unified_index["#{class_name}##{method_name}"]
135
+ return nil unless signature
136
+
137
+ # Parse the signature return type
138
+ parse_rbi_return_type(signature)
139
+ end
140
+
141
+ # Internal: Build a unified index from all RBI files.
142
+ #
143
+ # Returns Hash mapping "ClassName#method_name" => return_type_string
144
+ def rbi_unified_index
145
+ return @rbi_unified_index if defined?(@rbi_unified_index)
146
+
147
+ @rbi_unified_index = {}
148
+ return @rbi_unified_index unless rbi_path&.exist?
149
+
150
+ # Find RBI files, excluding gems/ (third-party gems won't have serializer methods)
151
+ # Only check annotations/ and dsl/ directories for performance
152
+ rbi_files = [
153
+ *Pathname.glob(rbi_path.join("annotations", "**", "*.rbi")),
154
+ *Pathname.glob(rbi_path.join("dsl", "**", "*.rbi")),
155
+ ]
156
+
157
+ # Parse each file and merge into unified index
158
+ rbi_files.each do |rbi_file|
159
+ tree = parse_rbi_file_cached(rbi_file)
160
+ next unless tree
161
+
162
+ # Extract all method signatures from this file
163
+ tree.index.each do |fqn, nodes|
164
+ next unless fqn.include?("#") # Only instance methods
165
+
166
+ nodes.each do |node|
167
+ return_type = node&.sigs&.first&.return_type
168
+ @rbi_unified_index[fqn] ||= return_type if return_type
169
+ end
170
+ end
171
+ rescue
172
+ # Ignore errors in individual files
173
+ next
174
+ end
175
+
176
+ @rbi_unified_index
177
+ end
178
+
179
+ # Internal: Parse an RBI file and cache the result.
180
+ #
181
+ # Returns parsed RBI tree or nil if parsing fails
182
+ def parse_rbi_file_cached(rbi_file)
183
+ @rbi_tree_cache ||= {}
184
+ @rbi_tree_cache[rbi_file.to_s] ||= ::RBI::Parser.parse_file(rbi_file)
185
+ rescue
186
+ # Cache the failure to avoid re-parsing
187
+ @rbi_tree_cache[rbi_file.to_s] = nil
188
+ end
189
+
190
+ # Internal: Parse a return type from RBI signature using RBI type system.
191
+ #
192
+ # type_string - String like "String", "T.nilable(Integer)", "T::Array[String]"
193
+ #
194
+ # Returns Hash with :typespec_type, :nilable, :array or nil
195
+ def parse_rbi_return_type(type_string)
196
+ type_string&.strip&.then { |s| ::RBI::Type.parse_string(s) }&.then { |t| rbi_type_to_typespec(t) }
197
+ rescue
198
+ # Parsing failed - return nil
199
+ nil
200
+ end
201
+
202
+ # Internal: Convert an RBI::Type to TypeSpec type information.
203
+ #
204
+ # rbi_type - RBI::Type object (Nilable, Generic, Simple, etc.)
205
+ #
206
+ # Returns Hash with :typespec_type, :nilable, :array or nil
207
+ def rbi_type_to_typespec(rbi_type)
208
+ result = {
209
+ typespec_type: nil,
210
+ nilable: false,
211
+ array: false,
212
+ }
213
+
214
+ # Unwrap T.nilable
215
+ if rbi_type.is_a?(::RBI::Type::Nilable)
216
+ result[:nilable] = true
217
+ rbi_type = rbi_type.type
218
+ end
219
+
220
+ # Unwrap T::Array
221
+ if rbi_type.is_a?(::RBI::Type::Generic) &&
222
+ rbi_type.name.in?(["::T::Array", "T::Array"])
223
+ result[:array] = true
224
+ rbi_type = rbi_type.params.first
225
+ end
226
+
227
+ # Extract the base type name
228
+ type_name = case rbi_type
229
+ when ::RBI::Type::Simple, ::RBI::Type::Generic
230
+ rbi_type.name
231
+ else
232
+ rbi_type.to_s
233
+ end
234
+
235
+ # Map to TypeSpec type (remove leading :: if present)
236
+ result[:typespec_type] = map_rbi_type(type_name.delete_prefix("::"))
237
+
238
+ result[:typespec_type] ? result : nil
239
+ end
240
+
241
+ # Internal: Map an RBI type string to TypeSpec type.
242
+ def map_rbi_type(type_string)
243
+ TypeSpecFromSerializers.config.sorbet_to_typespec_type_mapping[type_string] ||
244
+ ((type_string == "T::Boolean") ? :boolean : type_string)
245
+ end
246
+
247
+ # Internal: Extract type information from a Method object.
248
+ def extract_from_method(method)
249
+ sig = T::Utils.signature_for_method(method)
250
+ return nil unless sig&.return_type
251
+
252
+ sorbet_type_to_typespec(sig.return_type)
253
+ rescue
254
+ # Sorbet introspection failed, fall back to other inference methods
255
+ nil
256
+ end
257
+
258
+ # Internal: Convert a Sorbet type object to TypeSpec type information.
259
+ #
260
+ # sorbet_type - The Sorbet type object from the signature
261
+ #
262
+ # Returns Hash with :typespec_type, :nilable, :array, :type_class keys
263
+ # Returns nil if type cannot be mapped
264
+ def sorbet_type_to_typespec(sorbet_type)
265
+ base_result = {typespec_type: nil, nilable: false, array: false, type_class: nil}
266
+
267
+ # Unwrap T.nilable if present
268
+ if nilable?(sorbet_type)
269
+ base_result[:nilable] = true
270
+ sorbet_type = unwrap_nilable(sorbet_type)
271
+ end
272
+
273
+ # Dispatch to specific type handlers
274
+ handle_array_type(sorbet_type, base_result) ||
275
+ handle_hash_type(sorbet_type, base_result) ||
276
+ handle_set_type(sorbet_type, base_result) ||
277
+ handle_union_type(sorbet_type, base_result) ||
278
+ handle_untyped(sorbet_type, base_result) ||
279
+ handle_simple_type(sorbet_type, base_result)
280
+ end
281
+
282
+ # Internal: Handle T::Array types
283
+ def handle_array_type(sorbet_type, result)
284
+ return nil unless sorbet_type.is_a?(T::Types::TypedArray)
285
+
286
+ result[:array] = true
287
+ element_type = sorbet_type.type
288
+
289
+ # Shape types inside arrays: T::Array[{lon: Float, lat: Float}]
290
+ if shape_type = extract_shape_typespec(element_type)
291
+ return result.merge(typespec_type: shape_type)
292
+ end
293
+
294
+ # Nested complex types: T::Array[T::Array[X]], T::Array[T::Hash[K,V]]
295
+ if complex_element?(element_type)
296
+ nested = sorbet_type_to_typespec(element_type)
297
+ return nested&.then { |n|
298
+ typespec = n[:array] ? "#{n[:typespec_type]}[]" : n[:typespec_type]
299
+ result.merge(typespec_type: typespec)
300
+ }
301
+ end
302
+
303
+ # Simple array elements
304
+ handle_simple_type(element_type, result)
305
+ end
306
+
307
+ # Internal: Handle T::Hash types
308
+ def handle_hash_type(sorbet_type, result)
309
+ return nil unless sorbet_type.is_a?(T::Types::TypedHash)
310
+
311
+ value_type = map_sorbet_type(sorbet_type.values)
312
+ result.merge(typespec_type: "Record<#{value_type}>")
313
+ end
314
+
315
+ # Internal: Handle T::Set types (converted to arrays in TypeSpec)
316
+ def handle_set_type(sorbet_type, result)
317
+ return nil unless defined?(T::Types::TypedSet) && sorbet_type.is_a?(T::Types::TypedSet)
318
+
319
+ element_type = map_sorbet_type(sorbet_type.type)
320
+ result.merge(typespec_type: element_type, array: true)
321
+ end
322
+
323
+ # Internal: Handle T.any union types
324
+ def handle_union_type(sorbet_type, result)
325
+ return nil unless sorbet_type.is_a?(T::Types::Union) && !nilable?(sorbet_type)
326
+
327
+ non_nil_types = sorbet_type.types.reject { |t| t == T::Utils.coerce(NilClass) }
328
+
329
+ case non_nil_types.size
330
+ when 1
331
+ sorbet_type_to_typespec(non_nil_types.first)
332
+ else
333
+ mapped_types = non_nil_types.map { |t| map_sorbet_type(t) }.compact.uniq
334
+ typespec = mapped_types.size == 1 ? mapped_types.first : mapped_types.join(" | ")
335
+ result.merge(typespec_type: typespec)
336
+ end
337
+ end
338
+
339
+ # Internal: Handle T.untyped
340
+ def handle_untyped(sorbet_type, result)
341
+ return nil unless sorbet_type.is_a?(T::Types::Untyped)
342
+
343
+ result.merge(typespec_type: "unknown")
344
+ end
345
+
346
+ # Internal: Handle simple types (String, Integer, custom classes, etc.)
347
+ def handle_simple_type(sorbet_type, result)
348
+ result[:type_class] = extract_type_class(sorbet_type)
349
+ result[:typespec_type] = map_sorbet_type(sorbet_type)
350
+ result[:typespec_type] ? result : nil
351
+ end
352
+
353
+ # Internal: Extract shape type from FixedHash (e.g., {lon: Float, lat: Float})
354
+ def extract_shape_typespec(type)
355
+ return nil unless defined?(T::Types::FixedHash) && type.is_a?(T::Types::FixedHash)
356
+
357
+ # Access shape fields through multiple fallback paths
358
+ types_hash = [:@types, :@inner_types].find do |ivar|
359
+ type.instance_variable_get(ivar) rescue nil
360
+ end&.then { |ivar| type.instance_variable_get(ivar) } ||
361
+ (type.types if type.respond_to?(:types))
362
+
363
+ return nil unless types_hash
364
+
365
+ fields = types_hash.transform_values { |v| map_sorbet_type(v) }
366
+ "{#{fields.map { |k, v| "#{k}: #{v}" }.join(", ")}}"
367
+ end
368
+
369
+ # Internal: Check if type is a complex nested type
370
+ def complex_element?(type)
371
+ type.is_a?(T::Types::TypedArray) ||
372
+ type.is_a?(T::Types::TypedHash) ||
373
+ (defined?(T::Types::TypedSet) && type.is_a?(T::Types::TypedSet))
374
+ end
375
+
376
+ # Internal: Extract the Ruby class from a Sorbet type object.
377
+ #
378
+ # sorbet_type - The Sorbet type object
379
+ #
380
+ # Returns Class or nil
381
+ def extract_type_class(sorbet_type)
382
+ # Try multiple extraction strategies in order of preference
383
+ extract_from_pair_union(sorbet_type) ||
384
+ extract_from_typed_wrapper(sorbet_type) ||
385
+ extract_direct_class(sorbet_type)
386
+ rescue
387
+ # Type introspection failed - return nil
388
+ end
389
+
390
+ # Internal: Extract class from SimplePairUnion (e.g., T::Boolean)
391
+ def extract_from_pair_union(sorbet_type)
392
+ return nil unless sorbet_type.instance_of?(::T::Private::Types::SimplePairUnion)
393
+
394
+ [:@raw_a, :@raw_b]
395
+ .map { |ivar| sorbet_type.instance_variable_get(ivar) }
396
+ .reject { |type| type == NilClass }
397
+ .find { |type| type.is_a?(Class) }
398
+ end
399
+
400
+ # Internal: Extract class from typed wrappers (TypedArray, Simple, etc.)
401
+ def extract_from_typed_wrapper(sorbet_type)
402
+ [:type, :raw_type]
403
+ .lazy
404
+ .select { |method| sorbet_type.respond_to?(method) }
405
+ .map { |method| sorbet_type.public_send(method) }
406
+ .find { |value| value.is_a?(Class) }
407
+ end
408
+
409
+ # Internal: Extract class if sorbet_type itself is a Class
410
+ def extract_direct_class(sorbet_type)
411
+ sorbet_type if sorbet_type.is_a?(Class)
412
+ end
413
+
414
+ # Internal: Check if a Sorbet type is nilable (T.nilable wrapper).
415
+ def nilable?(sorbet_type)
416
+ # Handle SimplePairUnion (T.nilable in runtime)
417
+ if sorbet_type.instance_of?(::T::Private::Types::SimplePairUnion)
418
+ return sorbet_type.instance_variable_get(:@raw_b) == NilClass ||
419
+ sorbet_type.instance_variable_get(:@raw_a) == NilClass
420
+ end
421
+
422
+ # T.nilable wraps types in a T::Types::Union with NilClass
423
+ sorbet_type.is_a?(T::Types::Union) &&
424
+ sorbet_type.types.any? { |t| t == T::Utils.coerce(NilClass) }
425
+ end
426
+
427
+ # Internal: Unwrap T.nilable to get the underlying type.
428
+ def unwrap_nilable(sorbet_type)
429
+ return sorbet_type unless nilable?(sorbet_type)
430
+
431
+ # Handle SimplePairUnion
432
+ if sorbet_type.instance_of?(::T::Private::Types::SimplePairUnion)
433
+ raw_a, raw_b = sorbet_type.instance_variable_get(:@raw_a), sorbet_type.instance_variable_get(:@raw_b)
434
+ return (raw_a == NilClass) ? raw_b : raw_a
435
+ end
436
+
437
+ # Find the non-nil type in the union
438
+ sorbet_type.types.find { |t| t != T::Utils.coerce(NilClass) }
439
+ end
440
+
441
+ # Internal: Map a Sorbet type to a TypeSpec type.
442
+ #
443
+ # Returns Symbol for built-in types, String for custom types, or nil
444
+ def map_sorbet_type(sorbet_type)
445
+ type_class = extract_type_class(sorbet_type)
446
+ return nil unless type_class
447
+
448
+ type_name = type_class.name
449
+ return "unknown" unless type_name
450
+
451
+ config.sorbet_to_typespec_type_mapping[type_name] ||
452
+ (type_name == "T::Boolean" ? :boolean : type_name)
453
+ end
454
+
455
+ # Internal: Shorthand for config access
456
+ def config
457
+ TypeSpecFromSerializers.config
458
+ end
459
+ end
460
+ end
461
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module TypeSpecFromSerializers
4
4
  # Public: This library adheres to semantic versioning.
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.1"
6
6
  end
@@ -2,4 +2,6 @@
2
2
 
3
3
  require_relative "typespec_from_serializers/version"
4
4
  require_relative "typespec_from_serializers/dsl"
5
+ require_relative "typespec_from_serializers/sorbet"
6
+ require_relative "typespec_from_serializers/rbi"
5
7
  require_relative "typespec_from_serializers/railtie"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typespec_from_serializers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Danila Poyarkov
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2025-03-01 00:00:00.000000000 Z
12
+ date: 2025-12-19 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: railties
@@ -59,6 +59,34 @@ dependencies:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
61
  version: '3.2'
62
+ - !ruby/object:Gem::Dependency
63
+ name: rbi
64
+ requirement: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.3'
69
+ type: :runtime
70
+ prerelease: false
71
+ version_requirements: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.3'
76
+ - !ruby/object:Gem::Dependency
77
+ name: prism
78
+ requirement: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 1.0.0
83
+ type: :runtime
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 1.0.0
62
90
  - !ruby/object:Gem::Dependency
63
91
  name: bundler
64
92
  requirement: !ruby/object:Gem::Requirement
@@ -241,6 +269,20 @@ dependencies:
241
269
  - - ">="
242
270
  - !ruby/object:Gem::Version
243
271
  version: '0'
272
+ - !ruby/object:Gem::Dependency
273
+ name: sorbet-runtime
274
+ requirement: !ruby/object:Gem::Requirement
275
+ requirements:
276
+ - - ">="
277
+ - !ruby/object:Gem::Version
278
+ version: '0'
279
+ type: :development
280
+ prerelease: false
281
+ version_requirements: !ruby/object:Gem::Requirement
282
+ requirements:
283
+ - - ">="
284
+ - !ruby/object:Gem::Version
285
+ version: '0'
244
286
  description: typespec_from_serializers helps you by automatically generating TypeSpec
245
287
  descriptions for your JSON serializers, allowing you typecheck your frontend code
246
288
  to ship fast and with confidence.
@@ -256,8 +298,15 @@ files:
256
298
  - README.md
257
299
  - lib/typespec_from_serializers.rb
258
300
  - lib/typespec_from_serializers/dsl.rb
301
+ - lib/typespec_from_serializers/dsl/controller.rb
302
+ - lib/typespec_from_serializers/dsl/serializer.rb
259
303
  - lib/typespec_from_serializers/generator.rb
304
+ - lib/typespec_from_serializers/io.rb
305
+ - lib/typespec_from_serializers/openapi_compiler.rb
260
306
  - lib/typespec_from_serializers/railtie.rb
307
+ - lib/typespec_from_serializers/rbi.rb
308
+ - lib/typespec_from_serializers/runner.rb
309
+ - lib/typespec_from_serializers/sorbet.rb
261
310
  - lib/typespec_from_serializers/version.rb
262
311
  homepage: https://github.com/dannote/typespec_from_serializers
263
312
  licenses: