sorbet-baml 0.0.1 → 0.2.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.
@@ -0,0 +1,170 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module SorbetBaml
5
+ # Extracts documentation comments from Sorbet struct and enum source files
6
+ class CommentExtractor
7
+ extend T::Sig
8
+
9
+ sig { params(klass: T.class_of(T::Struct)).returns(T::Hash[String, T.nilable(String)]) }
10
+ def self.extract_field_comments(klass)
11
+ # First try to get descriptions from the description extractor (extra field)
12
+ descriptions = DescriptionExtractor.extract_prop_descriptions(klass)
13
+
14
+ # Then fall back to comment-based extraction for any missing descriptions
15
+ comments = {}
16
+ source_file = find_source_file(klass)
17
+
18
+ if source_file && File.exist?(source_file)
19
+ lines = File.readlines(source_file)
20
+ extract_comments_from_lines(lines, T.must(T.must(klass.name).split('::').last), comments)
21
+ end
22
+
23
+ # Merge with priority: description parameters > comments
24
+ descriptions.merge(comments) { |key, desc_param, comment| desc_param }
25
+ end
26
+
27
+ sig { params(klass: T.class_of(T::Enum)).returns(T::Hash[String, T.nilable(String)]) }
28
+ def self.extract_enum_comments(klass)
29
+ comments = {}
30
+ source_file = find_source_file(klass)
31
+
32
+ return comments unless source_file && File.exist?(source_file)
33
+
34
+ lines = File.readlines(source_file)
35
+ extract_enum_comments_from_lines(lines, T.must(T.must(klass.name).split('::').last), comments)
36
+
37
+ comments
38
+ end
39
+
40
+ private
41
+
42
+ sig { params(klass: T::Class[T.anything]).returns(T.nilable(String)) }
43
+ def self.find_source_file(klass)
44
+ # Try to find where the class was defined
45
+ # This is a heuristic approach since Ruby doesn't provide reliable source location for classes
46
+
47
+ # Method 1: Check if any methods have source location
48
+ begin
49
+ if klass.respond_to?(:new) && klass.method(:new).respond_to?(:source_location)
50
+ location = klass.method(:new).source_location
51
+ return location[0] if location
52
+ end
53
+ rescue
54
+ # Ignore errors
55
+ end
56
+
57
+ # Method 2: Look at the current call stack for files that might contain the class
58
+ caller_locations.each do |location|
59
+ file_path = location.absolute_path || location.path
60
+ next unless file_path && File.exist?(file_path)
61
+
62
+ # Read the file and check if it contains the class definition
63
+ begin
64
+ content = File.read(file_path)
65
+ class_name = T.must(klass.name).split('::').last
66
+ if content.match(/class\s+#{Regexp.escape(T.must(class_name))}\s*</)
67
+ return file_path
68
+ end
69
+ rescue
70
+ # Ignore file read errors
71
+ end
72
+ end
73
+
74
+ nil
75
+ end
76
+
77
+ sig { params(lines: T::Array[String], class_name: String, comments: T::Hash[String, T.nilable(String)]).void }
78
+ def self.extract_comments_from_lines(lines, class_name, comments)
79
+ in_target_class = T.let(false, T::Boolean)
80
+ current_comment = T.let(nil, T.nilable(String))
81
+ brace_depth = 0
82
+
83
+ lines.each do |line|
84
+ stripped = line.strip
85
+
86
+ # Check if we're entering the target class
87
+ if stripped.match(/^class\s+#{Regexp.escape(class_name)}\s*<\s*T::Struct/)
88
+ in_target_class = true
89
+ brace_depth = 0
90
+ next
91
+ end
92
+
93
+ next unless in_target_class
94
+
95
+ # Track brace depth to handle nested classes
96
+ brace_depth += stripped.count('{')
97
+ brace_depth -= stripped.count('}')
98
+
99
+ # Exit when we reach the end of the class
100
+ if stripped == 'end' && brace_depth == 0
101
+ break
102
+ end
103
+
104
+ # Extract comment
105
+ if stripped.start_with?('#')
106
+ comment_text = T.must(stripped[1..-1]).strip
107
+ current_comment = current_comment ? "#{current_comment} #{comment_text}" : comment_text
108
+ elsif stripped.match(/^const\s+:(\w+)/) && current_comment
109
+ field_name = T.must(stripped.match(/^const\s+:(\w+)/))[1]
110
+ comments[T.must(field_name)] = current_comment
111
+ current_comment = nil
112
+ elsif !stripped.empty? && !stripped.start_with?('#')
113
+ # Reset comment if we hit non-comment, non-const line
114
+ current_comment = nil
115
+ end
116
+ end
117
+ end
118
+
119
+ sig { params(lines: T::Array[String], class_name: String, comments: T::Hash[String, T.nilable(String)]).void }
120
+ def self.extract_enum_comments_from_lines(lines, class_name, comments)
121
+ in_target_class = T.let(false, T::Boolean)
122
+ in_enums_block = T.let(false, T::Boolean)
123
+ current_comment = T.let(nil, T.nilable(String))
124
+
125
+ lines.each do |line|
126
+ stripped = line.strip
127
+
128
+ # Check if we're entering the target enum class
129
+ if stripped.match(/^class\s+#{Regexp.escape(class_name)}\s*<\s*T::Enum/)
130
+ in_target_class = true
131
+ next
132
+ end
133
+
134
+ next unless in_target_class
135
+
136
+ # Check if we're in the enums block
137
+ if stripped == 'enums do'
138
+ in_enums_block = true
139
+ next
140
+ end
141
+
142
+ # Exit enums block
143
+ if in_enums_block && stripped == 'end'
144
+ in_enums_block = false
145
+ next
146
+ end
147
+
148
+ # Exit class
149
+ if stripped == 'end' && !in_enums_block
150
+ break
151
+ end
152
+
153
+ next unless in_enums_block
154
+
155
+ # Extract comment
156
+ if stripped.start_with?('#')
157
+ comment_text = T.must(stripped[1..-1]).strip
158
+ current_comment = current_comment ? "#{current_comment} #{comment_text}" : comment_text
159
+ elsif stripped.match(/^(\w+)\s*=\s*new/) && current_comment
160
+ enum_name = T.must(stripped.match(/^(\w+)\s*=\s*new/))[1]
161
+ comments[T.must(enum_name)] = current_comment
162
+ current_comment = nil
163
+ elsif !stripped.empty? && !stripped.start_with?('#')
164
+ # Reset comment if we hit non-comment, non-enum line
165
+ current_comment = nil
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -2,6 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "sorbet-runtime"
5
+ require "set"
6
+ require_relative "comment_extractor"
5
7
 
6
8
  module SorbetBaml
7
9
  # Main converter class for transforming Sorbet types to BAML
@@ -16,31 +18,179 @@ module SorbetBaml
16
18
  sig { params(klasses: T::Array[T.class_of(T::Struct)], options: T::Hash[Symbol, T.untyped]).returns(String) }
17
19
  def self.from_structs(klasses, options = {})
18
20
  converter = new(options)
19
- klasses.map { |klass| converter.convert_struct(klass) }.join("\n\n")
21
+
22
+ if converter.instance_variable_get(:@include_dependencies)
23
+ # When dependencies are enabled, collect all unique dependencies and convert once
24
+ all_dependencies = Set.new
25
+ enum_dependencies = Set.new
26
+
27
+ klasses.each do |klass|
28
+ deps = DependencyResolver.resolve_dependencies(klass)
29
+ all_dependencies.merge(deps)
30
+ enum_deps = converter.send(:find_enum_dependencies, deps)
31
+ enum_dependencies.merge(enum_deps)
32
+ end
33
+
34
+ # Convert all unique types
35
+ converted_types = []
36
+ enum_dependencies.each { |enum_klass| converted_types << converter.convert_enum(enum_klass) }
37
+ all_dependencies.each { |struct_klass| converted_types << converter.send(:convert_single_struct, struct_klass) }
38
+
39
+ converted_types.join("\n\n")
40
+ else
41
+ # When dependencies are disabled, convert each struct individually
42
+ klasses.map { |klass| converter.send(:convert_single_struct, klass) }.join("\n\n")
43
+ end
44
+ end
45
+
46
+ sig { params(klass: T.class_of(T::Enum), options: T::Hash[Symbol, T.untyped]).returns(String) }
47
+ def self.from_enum(klass, options = {})
48
+ new(options).convert_enum(klass)
20
49
  end
21
50
 
22
51
  sig { params(options: T::Hash[Symbol, T.untyped]).void }
23
52
  def initialize(options = {})
24
53
  @options = options
25
54
  @indent_size = T.let(options.fetch(:indent_size, 2), Integer)
26
- @include_descriptions = T.let(options.fetch(:include_descriptions, false), T::Boolean)
55
+ @include_descriptions = T.let(options.fetch(:include_descriptions, true), T::Boolean)
56
+ @include_dependencies = T.let(options.fetch(:include_dependencies, true), T::Boolean)
27
57
  end
28
58
 
29
59
  sig { params(klass: T.class_of(T::Struct)).returns(String) }
30
60
  def convert_struct(klass)
61
+ if @include_dependencies
62
+ # Get all dependencies in correct order and convert them all
63
+ dependencies = DependencyResolver.resolve_dependencies(klass)
64
+
65
+ # Also find all enum dependencies
66
+ enum_dependencies = find_enum_dependencies(dependencies)
67
+
68
+ # Convert enums first, then structs
69
+ converted_types = []
70
+ enum_dependencies.each { |enum_klass| converted_types << convert_enum(enum_klass) }
71
+ dependencies.each { |dep_klass| converted_types << convert_single_struct(dep_klass) }
72
+
73
+ converted_types.join("\n\n")
74
+ else
75
+ # Just convert the single struct
76
+ convert_single_struct(klass)
77
+ end
78
+ end
79
+
80
+ sig { params(klass: T.class_of(T::Enum)).returns(String) }
81
+ def convert_enum(klass)
82
+ class_name = klass.name || klass.to_s
83
+ simple_name = class_name.split('::').last
84
+ lines = ["enum #{simple_name} {"]
85
+
86
+ # Extract comments if requested
87
+ comments = @include_descriptions ? CommentExtractor.extract_enum_comments(klass) : {}
88
+
89
+ # Get all enum values by calling values method
90
+ enum_values = klass.values
91
+ enum_values.each do |enum_instance|
92
+ value = enum_instance.serialize
93
+
94
+ # Find the constant name for this value
95
+ constant_name = T.let(nil, T.nilable(String))
96
+ klass.constants.each do |const_name|
97
+ const_value = klass.const_get(const_name)
98
+ if const_value.is_a?(klass) && const_value.serialize == value
99
+ constant_name = const_name.to_s
100
+ break
101
+ end
102
+ end
103
+
104
+ line = "#{' ' * @indent_size}\"#{value}\""
105
+
106
+ # Add description if available (BAML uses @description annotations, not comments)
107
+ if @include_descriptions && constant_name && comments[constant_name]
108
+ line += " @description(\"#{T.must(comments[constant_name]).gsub('"', '\\"')}\")"
109
+ end
110
+
111
+ lines << line
112
+ end
113
+
114
+ lines << "}"
115
+ lines.join("\n")
116
+ end
117
+
118
+ private
119
+
120
+ sig { params(klass: T.class_of(T::Struct)).returns(String) }
121
+ def convert_single_struct(klass)
31
122
  props = klass.props
32
123
 
33
124
  class_name = klass.name || klass.to_s
34
125
  simple_name = class_name.split('::').last
35
126
  lines = ["class #{simple_name} {"]
36
127
 
128
+ # Extract comments if requested
129
+ comments = @include_descriptions ? CommentExtractor.extract_field_comments(klass) : {}
130
+
37
131
  props.each do |name, prop_info|
38
132
  baml_type = TypeMapper.map_type(prop_info[:type_object])
39
- lines << "#{' ' * @indent_size}#{name} #{baml_type}"
133
+ line = "#{' ' * @indent_size}#{name} #{baml_type}"
134
+
135
+ # Add description if available (BAML uses @description annotations)
136
+ if @include_descriptions && comments[name.to_s]
137
+ escaped_comment = T.must(comments[name.to_s]).gsub('"', '\\"')
138
+ line += " @description(\"#{escaped_comment}\")"
139
+ end
140
+
141
+ lines << line
40
142
  end
41
143
 
42
144
  lines << "}"
43
145
  lines.join("\n")
44
146
  end
147
+
148
+ sig { params(struct_classes: T::Array[T.class_of(T::Struct)]).returns(T::Array[T.class_of(T::Enum)]) }
149
+ def find_enum_dependencies(struct_classes)
150
+ enum_deps = Set.new
151
+
152
+ struct_classes.each do |struct_klass|
153
+ struct_klass.props.each do |_name, prop_info|
154
+ type_object = prop_info[:type_object]
155
+ enum_deps.merge(extract_enum_types(type_object))
156
+ end
157
+ end
158
+
159
+ enum_deps.to_a
160
+ end
161
+
162
+ sig { params(type_object: T.untyped).returns(T::Array[T.class_of(T::Enum)]) }
163
+ def extract_enum_types(type_object)
164
+ return [] if type_object.nil?
165
+
166
+ case type_object
167
+ when T::Types::Simple
168
+ extract_enum_from_simple_type(type_object.raw_type)
169
+ when T::Types::TypedArray
170
+ extract_enum_types(type_object.type)
171
+ when T::Types::TypedHash
172
+ # Check both key and value types
173
+ key_types = extract_enum_types(type_object.keys)
174
+ value_types = extract_enum_types(type_object.values)
175
+ key_types + value_types
176
+ else
177
+ # Check if it's a union type
178
+ if type_object.respond_to?(:types)
179
+ type_object.types.flat_map { |t| extract_enum_types(t) }
180
+ else
181
+ []
182
+ end
183
+ end
184
+ end
185
+
186
+ sig { params(raw_type: T.untyped).returns(T::Array[T.class_of(T::Enum)]) }
187
+ def extract_enum_from_simple_type(raw_type)
188
+ # Check if this raw_type is a T::Enum subclass
189
+ if raw_type.is_a?(Class) && raw_type < T::Enum
190
+ [raw_type]
191
+ else
192
+ []
193
+ end
194
+ end
45
195
  end
46
196
  end
@@ -0,0 +1,99 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module SorbetBaml
7
+ # Resolves dependencies between T::Struct types and orders them topologically
8
+ class DependencyResolver
9
+ extend T::Sig
10
+
11
+ sig { params(klass: T.class_of(T::Struct)).returns(T::Array[T.class_of(T::Struct)]) }
12
+ def self.resolve_dependencies(klass)
13
+ new.resolve_dependencies(klass)
14
+ end
15
+
16
+ sig { void }
17
+ def initialize
18
+ @visited = T.let(Set.new, T::Set[T.class_of(T::Struct)])
19
+ @dependencies = T.let([], T::Array[T.class_of(T::Struct)])
20
+ end
21
+
22
+ sig { params(klass: T.class_of(T::Struct)).returns(T::Array[T.class_of(T::Struct)]) }
23
+ def resolve_dependencies(klass)
24
+ @visited.clear
25
+ @dependencies.clear
26
+
27
+ # Perform depth-first search to find all dependencies
28
+ visit(klass)
29
+
30
+ # Dependencies are already in correct topological order
31
+ # (dependencies first, then the types that depend on them)
32
+ @dependencies
33
+ end
34
+
35
+ private
36
+
37
+ sig { params(klass: T.class_of(T::Struct)).void }
38
+ def visit(klass)
39
+ return if @visited.include?(klass)
40
+
41
+ @visited.add(klass)
42
+
43
+ # Find all T::Struct dependencies in this class
44
+ struct_dependencies = find_struct_dependencies(klass)
45
+
46
+ # Visit dependencies first (depth-first)
47
+ struct_dependencies.each { |dep| visit(dep) }
48
+
49
+ # Add this class after its dependencies
50
+ @dependencies << klass
51
+ end
52
+
53
+ sig { params(klass: T.class_of(T::Struct)).returns(T::Array[T.class_of(T::Struct)]) }
54
+ def find_struct_dependencies(klass)
55
+ dependencies = []
56
+
57
+ klass.props.each do |_name, prop_info|
58
+ type_object = prop_info[:type_object]
59
+ dependencies.concat(extract_struct_types(type_object))
60
+ end
61
+
62
+ dependencies.uniq
63
+ end
64
+
65
+ sig { params(type_object: T.untyped).returns(T::Array[T.class_of(T::Struct)]) }
66
+ def extract_struct_types(type_object)
67
+ return [] if type_object.nil?
68
+
69
+ case type_object
70
+ when T::Types::Simple
71
+ extract_from_simple_type(type_object.raw_type)
72
+ when T::Types::TypedArray
73
+ extract_struct_types(type_object.type)
74
+ when T::Types::TypedHash
75
+ # Check both key and value types
76
+ key_types = extract_struct_types(type_object.keys)
77
+ value_types = extract_struct_types(type_object.values)
78
+ key_types + value_types
79
+ else
80
+ # Check if it's a union type
81
+ if type_object.respond_to?(:types)
82
+ type_object.types.flat_map { |t| extract_struct_types(t) }
83
+ else
84
+ []
85
+ end
86
+ end
87
+ end
88
+
89
+ sig { params(raw_type: T.untyped).returns(T::Array[T.class_of(T::Struct)]) }
90
+ def extract_from_simple_type(raw_type)
91
+ # Check if this raw_type is a T::Struct subclass
92
+ if raw_type.is_a?(Class) && raw_type < T::Struct
93
+ [raw_type]
94
+ else
95
+ []
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,34 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module SorbetBaml
7
+ # Extension module to add description: parameter support to T::Struct
8
+ module DescriptionExtension
9
+ extend T::Sig
10
+
11
+ # Override const to support description parameter
12
+ sig { params(name: Symbol, type: T.untyped, description: T.nilable(String), kwargs: T.untyped).void }
13
+ def const(name, type, description: nil, **kwargs)
14
+ if description
15
+ super(name, type, extra: { description: description }, **kwargs)
16
+ else
17
+ super(name, type, **kwargs)
18
+ end
19
+ end
20
+
21
+ # Override prop to support description parameter
22
+ sig { params(name: Symbol, type: T.untyped, description: T.nilable(String), kwargs: T.untyped).void }
23
+ def prop(name, type, description: nil, **kwargs)
24
+ if description
25
+ super(name, type, extra: { description: description }, **kwargs)
26
+ else
27
+ super(name, type, **kwargs)
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ # Automatically extend T::Struct with description support
34
+ T::Struct.extend(SorbetBaml::DescriptionExtension)
@@ -0,0 +1,36 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module SorbetBaml
7
+ # Extracts description parameters from T::Struct prop and const declarations
8
+ class DescriptionExtractor
9
+ extend T::Sig
10
+
11
+ sig { params(klass: T::Class[T.anything]).returns(T::Hash[String, T.nilable(String)]) }
12
+ def self.extract_prop_descriptions(klass)
13
+ descriptions = {}
14
+
15
+ # Check if this is a T::Struct with props
16
+ return descriptions unless klass.respond_to?(:props)
17
+
18
+ begin
19
+ T.unsafe(klass).props.each do |field_name, prop_info|
20
+ next unless prop_info.is_a?(Hash)
21
+
22
+ # Check if the prop has a description in the :extra field
23
+ extra = prop_info[:extra]
24
+ if extra.is_a?(Hash) && extra[:description].is_a?(String)
25
+ descriptions[field_name.to_s] = extra[:description]
26
+ end
27
+ end
28
+ rescue => e
29
+ # Handle any errors gracefully and return empty hash
30
+ return {}
31
+ end
32
+
33
+ descriptions
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module SorbetBaml
7
+ # Extensions to add BAML conversion methods to T::Enum
8
+ module EnumExtensions
9
+ extend T::Sig
10
+
11
+ # Convert this enum to BAML type definition
12
+ sig { params(options: T::Hash[Symbol, T.untyped]).returns(String) }
13
+ def to_baml(options = {})
14
+ baml_type_definition(options)
15
+ end
16
+
17
+ # Convert this enum to BAML type definition with options
18
+ sig { params(options: T::Hash[Symbol, T.untyped]).returns(String) }
19
+ def baml_type_definition(options = {})
20
+ SorbetBaml::Converter.from_enum(T.cast(self, T.class_of(T::Enum)), options)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module SorbetBaml
7
+ # Extensions to add BAML conversion methods to T::Struct
8
+ module StructExtensions
9
+ extend T::Sig
10
+
11
+ # Convert this struct to BAML type definition
12
+ sig { params(options: T::Hash[Symbol, T.untyped]).returns(String) }
13
+ def to_baml(options = {})
14
+ baml_type_definition(options)
15
+ end
16
+
17
+ # Convert this struct to BAML type definition with options
18
+ sig { params(options: T::Hash[Symbol, T.untyped]).returns(String) }
19
+ def baml_type_definition(options = {})
20
+ SorbetBaml::Converter.from_struct(T.cast(self, T.class_of(T::Struct)), options)
21
+ end
22
+ end
23
+ end
@@ -17,10 +17,12 @@ module SorbetBaml
17
17
  map_simple_type(type_object.raw_type)
18
18
  when T::Types::TypedArray
19
19
  map_array_type(type_object)
20
+ when T::Types::TypedHash
21
+ map_hash_type(type_object)
20
22
  else
21
- # Check if it's a union type (T.nilable)
23
+ # Check if it's a union type (T.nilable or T.any)
22
24
  if type_object.respond_to?(:types)
23
- map_nilable_type(type_object)
25
+ map_union_type(type_object)
24
26
  else
25
27
  # Fallback for unknown types
26
28
  "unknown"
@@ -46,8 +48,8 @@ module SorbetBaml
46
48
  when "Date", "DateTime", "Time"
47
49
  "string"
48
50
  else
49
- # Check if it's a T::Struct
50
- if raw_type < T::Struct
51
+ # Check if it's a T::Struct or T::Enum
52
+ if raw_type < T::Struct || raw_type < T::Enum
51
53
  type_name = raw_type.name || raw_type.to_s
52
54
  type_name.split('::').last || "unknown"
53
55
  else
@@ -57,23 +59,58 @@ module SorbetBaml
57
59
  end
58
60
 
59
61
  sig { params(type_object: T.untyped).returns(String) }
60
- def self.map_nilable_type(type_object)
61
- # Extract the non-nil type from the union
62
+ def self.map_union_type(type_object)
62
63
  types = type_object.types
63
- non_nil_type = types.find { |t| t.raw_type != NilClass }
64
+ nil_type = types.find { |t| t.raw_type == NilClass }
65
+ non_nil_types = types.reject { |t| t.raw_type == NilClass }
64
66
 
65
- if non_nil_type
66
- base_type = map_type(non_nil_type)
67
- "#{base_type}?"
67
+ if non_nil_types.empty?
68
+ return "null"
69
+ end
70
+
71
+ if non_nil_types.size == 1
72
+ # This is T.nilable(T) - single type with nil
73
+ base_type = map_type(non_nil_types.first)
74
+ return nil_type ? "#{base_type}?" : base_type
75
+ end
76
+
77
+ # This is T.any with multiple types
78
+ mapped_types = non_nil_types.map { |t| map_type(t) }
79
+
80
+ # Special case: TrueClass + FalseClass = bool
81
+ if mapped_types.sort == ["bool", "bool"]
82
+ union_string = "bool"
68
83
  else
69
- "null"
84
+ # Remove duplicates and join
85
+ union_string = mapped_types.uniq.join(" | ")
86
+ end
87
+
88
+ # If nil is present, wrap in parentheses and add ?
89
+ if nil_type
90
+ "(#{union_string})?"
91
+ else
92
+ union_string
70
93
  end
71
94
  end
72
95
 
73
96
  sig { params(type_object: T.untyped).returns(String) }
74
97
  def self.map_array_type(type_object)
75
98
  element_type = map_type(type_object.type)
76
- "#{element_type}[]"
99
+
100
+ # If element type contains union (|), wrap in parentheses for correct precedence
101
+ if element_type.include?("|")
102
+ "(#{element_type})[]"
103
+ else
104
+ "#{element_type}[]"
105
+ end
106
+ end
107
+
108
+ sig { params(type_object: T.untyped).returns(String) }
109
+ def self.map_hash_type(type_object)
110
+ # T::Types::TypedHash has keys and values methods
111
+ key_type = map_type(type_object.keys)
112
+ value_type = map_type(type_object.values)
113
+ "map<#{key_type}, #{value_type}>"
77
114
  end
78
115
  end
79
116
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SorbetBaml
4
- VERSION = "0.0.1"
4
+ VERSION = "0.2.0"
5
5
  end