sorbet-toon 0.1.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,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'sorbet-runtime'
5
+
6
+ module Sorbet
7
+ module Toon
8
+ module Reconstructor
9
+ class << self
10
+ def reconstruct(value, signature: nil, struct_class: nil, role: :output)
11
+ target_class = struct_class || resolve_struct_class(signature, role)
12
+ return value unless target_class
13
+
14
+ convert_hash_to_struct(value, target_class)
15
+ end
16
+
17
+ private
18
+
19
+ def resolve_struct_class(signature, role)
20
+ return nil unless signature
21
+
22
+ case role
23
+ when :input
24
+ signature.input_struct_class
25
+ else
26
+ signature.output_struct_class
27
+ end
28
+ end
29
+
30
+ def convert_hash_to_struct(hash, struct_class)
31
+ return hash unless hash.is_a?(Hash)
32
+
33
+ attributes = {}
34
+
35
+ struct_class.props.each do |prop_name, prop_info|
36
+ raw_value = fetch_value(hash, prop_name)
37
+ next if raw_value.nil?
38
+
39
+ type_object = prop_info[:type_object] || T::Utils.coerce(prop_info[:type])
40
+ attributes[prop_name] = convert_value(raw_value, type_object)
41
+ end
42
+
43
+ struct_class.new(**attributes)
44
+ rescue StandardError
45
+ hash
46
+ end
47
+
48
+ def fetch_value(hash, prop_name)
49
+ key = prop_name.to_s
50
+ return hash[key] if hash.key?(key)
51
+ return hash[prop_name] if hash.key?(prop_name)
52
+
53
+ sym_key = key.to_sym
54
+ hash[sym_key] if hash.key?(sym_key)
55
+ end
56
+
57
+ def convert_value(value, type_object)
58
+ return nil if value.nil?
59
+ return value unless type_object
60
+
61
+ case type_object
62
+ when T::Types::TypedArray
63
+ convert_typed_array(value, type_object)
64
+ when T::Types::TypedSet
65
+ convert_typed_set(value, type_object)
66
+ when T::Types::TypedHash
67
+ convert_typed_hash(value, type_object)
68
+ when T::Types::Simple
69
+ convert_simple(value, type_object)
70
+ when T::Types::Union
71
+ convert_union(value, type_object)
72
+ else
73
+ value
74
+ end
75
+ end
76
+
77
+ def convert_simple(value, simple_type)
78
+ raw = simple_type.raw_type
79
+ return convert_hash_to_struct(value, raw) if struct_class?(raw) && value.is_a?(Hash)
80
+ return deserialize_enum(raw, value) if enum_class?(raw) && !value.is_a?(raw)
81
+
82
+ value
83
+ end
84
+
85
+ def convert_typed_array(value, typed_array)
86
+ return value unless value.is_a?(Array)
87
+
88
+ value.map { |element| convert_value(element, typed_array.type) }
89
+ end
90
+
91
+ def convert_typed_set(value, typed_set)
92
+ return value unless value.is_a?(Array)
93
+
94
+ Set.new(value.map { |element| convert_value(element, typed_set.type) })
95
+ end
96
+
97
+ def convert_typed_hash(value, typed_hash)
98
+ return value unless value.is_a?(Hash)
99
+
100
+ value.each_with_object({}) do |(key, val), memo|
101
+ converted_key = coerce_hash_key(key, typed_hash.keys)
102
+ memo[converted_key] = convert_value(val, typed_hash.values)
103
+ end
104
+ end
105
+
106
+ def convert_union(value, union_type)
107
+ return nil if value.nil? && union_type.types.any? { |member| nil_type?(member) }
108
+
109
+ if value.is_a?(Hash)
110
+ explicit_type = union_struct_from_type_field(value, union_type)
111
+ return convert_value(value, explicit_type) if explicit_type
112
+ end
113
+
114
+ union_type.types.each do |member|
115
+ next if nil_type?(member)
116
+
117
+ converted = convert_value(value, member)
118
+ return converted unless converted.equal?(value)
119
+ end
120
+
121
+ value
122
+ end
123
+
124
+ def union_struct_from_type_field(hash, union_type)
125
+ type_name = hash['_type'] || hash[:_type]
126
+ return nil unless type_name
127
+
128
+ union_type.types.find do |member|
129
+ struct_type?(member) && struct_name(member.raw_type) == type_name
130
+ end
131
+ end
132
+
133
+ def struct_type?(type)
134
+ type.is_a?(T::Types::Simple) && struct_class?(type.raw_type)
135
+ end
136
+
137
+ def nil_type?(type)
138
+ type.is_a?(T::Types::Simple) && type.raw_type == NilClass
139
+ end
140
+
141
+ def struct_class?(klass)
142
+ klass.is_a?(Class) && klass < T::Struct
143
+ rescue StandardError
144
+ false
145
+ end
146
+
147
+ def enum_class?(klass)
148
+ klass.is_a?(Class) && klass < T::Enum
149
+ rescue StandardError
150
+ false
151
+ end
152
+
153
+ def deserialize_enum(enum_class, value)
154
+ return value if value.is_a?(enum_class)
155
+ return enum_class.deserialize(value) if enum_class.respond_to?(:deserialize)
156
+
157
+ enum_class.values.find { |member| member.serialize == value } || value
158
+ end
159
+
160
+ def coerce_hash_key(key, key_type)
161
+ return key unless key_type.is_a?(T::Types::Simple)
162
+
163
+ case key_type.raw_type
164
+ when Symbol
165
+ key.to_sym
166
+ when Integer
167
+ key.to_i
168
+ when Float
169
+ key.to_f
170
+ else
171
+ key.to_s
172
+ end
173
+ end
174
+
175
+ def struct_name(klass)
176
+ klass.name&.split('::')&.last
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constants'
4
+
5
+ module Sorbet
6
+ module Toon
7
+ module Shared
8
+ module LiteralUtils
9
+ module_function
10
+
11
+ def boolean_or_null_literal?(token)
12
+ return false if token.nil?
13
+
14
+ token == Constants::TRUE_LITERAL ||
15
+ token == Constants::FALSE_LITERAL ||
16
+ token == Constants::NULL_LITERAL
17
+ end
18
+
19
+ def numeric_literal?(token)
20
+ return false if token.nil? || token.empty?
21
+
22
+ # Reject numbers with leading zeros (except 0.x cases)
23
+ if token.length > 1 && token.start_with?('0') && token[1] != '.'
24
+ return false
25
+ end
26
+
27
+ numeric_value = Float(token)
28
+ numeric_value.finite?
29
+ rescue ArgumentError
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constants'
4
+ require_relative '../errors'
5
+
6
+ module Sorbet
7
+ module Toon
8
+ module Shared
9
+ module StringUtils
10
+ module_function
11
+
12
+ def escape_string(value)
13
+ value
14
+ .gsub('\\') { Constants::BACKSLASH * 2 }
15
+ .gsub('"', "#{Constants::BACKSLASH}#{Constants::DOUBLE_QUOTE}")
16
+ .gsub("\n", "#{Constants::BACKSLASH}n")
17
+ .gsub("\r", "#{Constants::BACKSLASH}r")
18
+ .gsub("\t", "#{Constants::BACKSLASH}t")
19
+ end
20
+
21
+ def unescape_string(value)
22
+ result = +''
23
+ i = 0
24
+ while i < value.length
25
+ char = value[i]
26
+ if char == Constants::BACKSLASH
27
+ raise Sorbet::Toon::DecodeError, 'Invalid escape sequence: backslash at end of string' if i + 1 >= value.length
28
+
29
+ next_char = value[i + 1]
30
+ case next_char
31
+ when 'n'
32
+ result << Constants::NEWLINE
33
+ when 't'
34
+ result << Constants::TAB
35
+ when 'r'
36
+ result << Constants::CARRIAGE_RETURN
37
+ when Constants::BACKSLASH
38
+ result << Constants::BACKSLASH
39
+ when Constants::DOUBLE_QUOTE
40
+ result << Constants::DOUBLE_QUOTE
41
+ else
42
+ raise Sorbet::Toon::DecodeError, "Invalid escape sequence: \\#{next_char}"
43
+ end
44
+ i += 2
45
+ next
46
+ end
47
+
48
+ result << char
49
+ i += 1
50
+ end
51
+ result
52
+ end
53
+
54
+ def find_closing_quote(content, start_index)
55
+ i = start_index + 1
56
+ while i < content.length
57
+ if content[i] == Constants::BACKSLASH && i + 1 < content.length
58
+ i += 2
59
+ next
60
+ end
61
+
62
+ return i if content[i] == Constants::DOUBLE_QUOTE
63
+
64
+ i += 1
65
+ end
66
+ -1
67
+ end
68
+
69
+ def find_unquoted_char(content, char, start_index = 0)
70
+ in_quotes = false
71
+ i = start_index
72
+
73
+ while i < content.length
74
+ if content[i] == Constants::BACKSLASH && i + 1 < content.length && in_quotes
75
+ i += 2
76
+ next
77
+ end
78
+
79
+ if content[i] == Constants::DOUBLE_QUOTE
80
+ in_quotes = !in_quotes
81
+ i += 1
82
+ next
83
+ end
84
+
85
+ return i if content[i] == char && !in_quotes
86
+
87
+ i += 1
88
+ end
89
+
90
+ -1
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constants'
4
+ require_relative 'literal_utils'
5
+
6
+ module Sorbet
7
+ module Toon
8
+ module Shared
9
+ module Validation
10
+ module_function
11
+
12
+ UNQUOTED_KEY_REGEX = /\A[A-Z_][\w.]*\z/i.freeze
13
+ NUMERIC_LIKE_REGEX = /\A-?\d+(?:\.\d+)?(?:e[+-]?\d+)?\z/i.freeze
14
+ LEADING_ZERO_REGEX = /\A0\d+\z/.freeze
15
+
16
+ def valid_unquoted_key?(key)
17
+ UNQUOTED_KEY_REGEX.match?(key)
18
+ end
19
+
20
+ def safe_unquoted?(value, delimiter = Constants::COMMA)
21
+ return false if value.nil? || value.empty?
22
+ return false if value != value.strip
23
+ return false if LiteralUtils.boolean_or_null_literal?(value) || numeric_like?(value)
24
+ return false if value.include?(Constants::COLON)
25
+ return false if value.include?(Constants::DOUBLE_QUOTE) || value.include?(Constants::BACKSLASH)
26
+ return false if value.match?(/[{}\[\]]/)
27
+ return false if value.match?(/[\n\r\t]/)
28
+ return false if value.include?(delimiter)
29
+ return false if value.start_with?(Constants::LIST_ITEM_MARKER)
30
+
31
+ true
32
+ end
33
+
34
+ def numeric_like?(value)
35
+ NUMERIC_LIKE_REGEX.match?(value) || LEADING_ZERO_REGEX.match?(value)
36
+ end
37
+ private_class_method :numeric_like?
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module Sorbet
6
+ module Toon
7
+ module SignatureFormatter
8
+ module_function
9
+
10
+ def describe_signature(signature_class, role)
11
+ struct_class = struct_for(signature_class, role)
12
+ return "No #{role} fields defined." unless struct_class
13
+
14
+ descriptors = field_descriptors(signature_class, role)
15
+ describe_struct(struct_class, descriptors)
16
+ end
17
+
18
+ def struct_for(signature_class, role)
19
+ return nil unless signature_class
20
+
21
+ case role
22
+ when :input
23
+ signature_class.input_struct_class if signature_class.respond_to?(:input_struct_class)
24
+ else
25
+ signature_class.output_struct_class if signature_class.respond_to?(:output_struct_class)
26
+ end
27
+ end
28
+ private_class_method :struct_for
29
+
30
+ def field_descriptors(signature_class, role)
31
+ return {} unless signature_class
32
+
33
+ case role
34
+ when :input
35
+ signature_class.respond_to?(:input_field_descriptors) ? signature_class.input_field_descriptors || {} : {}
36
+ else
37
+ signature_class.respond_to?(:output_field_descriptors) ? signature_class.output_field_descriptors || {} : {}
38
+ end
39
+ end
40
+ private_class_method :field_descriptors
41
+
42
+ def describe_struct(struct_class, descriptors)
43
+ lines = []
44
+
45
+ struct_class.props.each do |prop_name, prop_info|
46
+ descriptor = descriptors[prop_name]
47
+ type_object = descriptor&.type || prop_info[:type_object] || prop_info[:type]
48
+ type_info = describe_type(type_object)
49
+ optional = descriptor&.has_default || prop_info[:fully_optional]
50
+
51
+ line = "- #{prop_name}"
52
+ type_label = type_info[:label]
53
+ line << " (#{type_label}"
54
+ line << ', optional' if optional
55
+ line << ')'
56
+ if descriptor&.description
57
+ line << " — #{descriptor.description}"
58
+ end
59
+
60
+ lines << line
61
+
62
+ if type_info[:tabular_columns]
63
+ lines << " • Tabular columns: #{type_info[:tabular_columns].join(', ')}"
64
+ end
65
+ end
66
+
67
+ return "No fields defined." if lines.empty?
68
+
69
+ lines.join("\n")
70
+ end
71
+ private_class_method :describe_struct
72
+
73
+ def describe_type(type)
74
+ case type
75
+ when T::Types::TypedArray
76
+ inner = describe_type(type.type)
77
+ {
78
+ label: "Array<#{inner[:label]}>",
79
+ tabular_columns: inner[:tabular_columns]
80
+ }
81
+ when T::Types::TypedSet
82
+ inner = describe_type(type.type)
83
+ {
84
+ label: "Set<#{inner[:label]}>"
85
+ }
86
+ when T::Types::TypedHash
87
+ key = describe_type(type.keys)
88
+ value = describe_type(type.values)
89
+ {
90
+ label: "Hash<#{key[:label]} => #{value[:label]}>"
91
+ }
92
+ when T::Types::Union
93
+ members = type.types.reject { |member| nil_type?(member) }
94
+ labels = members.map { |member| describe_type(member)[:label] }.uniq
95
+ { label: labels.join(' | ') }
96
+ when T::Private::Types::TypeAlias
97
+ describe_type(type.aliased_type)
98
+ when T::Types::Simple
99
+ describe_class(type.raw_type)
100
+ when Class
101
+ describe_class(type)
102
+ else
103
+ { label: type_label_from_object(type) }
104
+ end
105
+ end
106
+ private_class_method :describe_type
107
+
108
+ def describe_class(klass)
109
+ return { label: 'nil' } if klass.nil?
110
+
111
+ if struct_class?(klass)
112
+ {
113
+ label: klass.name ? klass.name.split('::').last : 'Struct',
114
+ tabular_columns: klass.props.keys.map(&:to_s)
115
+ }
116
+ elsif enum_class?(klass)
117
+ values = klass.respond_to?(:values) ? klass.values.map(&:serialize).join(', ') : ''
118
+ { label: values.empty? ? klass.name || 'Enum' : "Enum<#{values}>" }
119
+ else
120
+ { label: primitive_label(klass) }
121
+ end
122
+ end
123
+ private_class_method :describe_class
124
+
125
+ def primitive_label(klass)
126
+ case klass.name
127
+ when 'String' then 'String'
128
+ when 'Integer' then 'Integer'
129
+ when 'Float' then 'Float'
130
+ when 'TrueClass', 'FalseClass' then 'Boolean'
131
+ when 'Numeric' then 'Number'
132
+ when 'Date' then 'Date'
133
+ when 'DateTime', 'Time' then 'DateTime'
134
+ else
135
+ klass.name || klass.to_s
136
+ end
137
+ end
138
+ private_class_method :primitive_label
139
+
140
+ def type_label_from_object(type)
141
+ if type.respond_to?(:name)
142
+ type.name
143
+ else
144
+ type.to_s
145
+ end
146
+ end
147
+ private_class_method :type_label_from_object
148
+
149
+ def struct_class?(klass)
150
+ klass.is_a?(Class) && klass < T::Struct
151
+ rescue StandardError
152
+ false
153
+ end
154
+ private_class_method :struct_class?
155
+
156
+ def enum_class?(klass)
157
+ klass.is_a?(Class) && klass < T::Enum
158
+ rescue StandardError
159
+ false
160
+ end
161
+ private_class_method :enum_class?
162
+
163
+ def nil_type?(type)
164
+ (type.is_a?(T::Types::Simple) && type.raw_type == NilClass) ||
165
+ type == T::Utils.coerce(NilClass)
166
+ rescue StandardError
167
+ false
168
+ end
169
+ private_class_method :nil_type?
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sorbet
4
+ module Toon
5
+ module StructExtensions
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def from_toon(payload, **options)
12
+ Sorbet::Toon.decode(payload, struct_class: self, **options)
13
+ end
14
+ end
15
+
16
+ def to_toon(**options)
17
+ Sorbet::Toon.encode(self, **options)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sorbet
4
+ module Toon
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ require_relative 'toon/version'
6
+ require_relative 'toon/errors'
7
+ require_relative 'toon/constants'
8
+ require_relative 'toon/codec'
9
+ require_relative 'toon/normalizer'
10
+ require_relative 'toon/config'
11
+ require_relative 'toon/encoder'
12
+ require_relative 'toon/decoder'
13
+ require_relative 'toon/reconstructor'
14
+ require_relative 'toon/signature_formatter'
15
+ require_relative 'toon/struct_extensions'
16
+ require_relative 'toon/enum_extensions'
17
+
18
+ module Sorbet
19
+ module Toon
20
+ class << self
21
+ def encode(value, **options)
22
+ Encoder.encode(value, config: config, **options)
23
+ end
24
+
25
+ def decode(payload, struct_class: nil, **options)
26
+ Decoder.decode(payload, config: config, struct_class: struct_class, **options)
27
+ end
28
+
29
+ def configure
30
+ yield(config)
31
+ end
32
+
33
+ def config
34
+ @config ||= Config.new
35
+ end
36
+
37
+ def reset_config!(new_config = nil)
38
+ @config = new_config&.copy || Config.new
39
+ end
40
+
41
+ def enable_extensions!
42
+ return if extensions_enabled?
43
+
44
+ T::Struct.include(Sorbet::Toon::StructExtensions)
45
+ T::Enum.include(Sorbet::Toon::EnumExtensions)
46
+ @extensions_enabled = true
47
+ end
48
+
49
+ def extensions_enabled?
50
+ !!@extensions_enabled
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ Sorbet::Toon.enable_extensions!
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sorbet-toon
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Vicente Reig Rincón de Arellano
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sorbet-runtime
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.5'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.5'
26
+ description: Ruby port of the TOON encoder/decoder used inside DSPy.rb. Provides Sorbet-aware
27
+ normalization, reconstruction, and prompt-ready helpers so signatures can round-trip
28
+ through TOON without hand-written serializers.
29
+ email:
30
+ - hey@vicente.services
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - LICENSE
36
+ - lib/sorbet/toon.rb
37
+ - lib/sorbet/toon/README.md
38
+ - lib/sorbet/toon/codec.rb
39
+ - lib/sorbet/toon/config.rb
40
+ - lib/sorbet/toon/constants.rb
41
+ - lib/sorbet/toon/decode/decoders.rb
42
+ - lib/sorbet/toon/decode/parser.rb
43
+ - lib/sorbet/toon/decode/scanner.rb
44
+ - lib/sorbet/toon/decode/validation.rb
45
+ - lib/sorbet/toon/decoder.rb
46
+ - lib/sorbet/toon/encode/encoders.rb
47
+ - lib/sorbet/toon/encode/normalize.rb
48
+ - lib/sorbet/toon/encode/primitives.rb
49
+ - lib/sorbet/toon/encode/writer.rb
50
+ - lib/sorbet/toon/encoder.rb
51
+ - lib/sorbet/toon/enum_extensions.rb
52
+ - lib/sorbet/toon/errors.rb
53
+ - lib/sorbet/toon/normalizer.rb
54
+ - lib/sorbet/toon/reconstructor.rb
55
+ - lib/sorbet/toon/shared/literal_utils.rb
56
+ - lib/sorbet/toon/shared/string_utils.rb
57
+ - lib/sorbet/toon/shared/validation.rb
58
+ - lib/sorbet/toon/signature_formatter.rb
59
+ - lib/sorbet/toon/struct_extensions.rb
60
+ - lib/sorbet/toon/version.rb
61
+ homepage: https://github.com/vicentereig/dspy.rb/blob/main/lib/sorbet/toon/README.md
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://github.com/vicentereig/dspy.rb/blob/main/lib/sorbet/toon/README.md
66
+ documentation_uri: https://github.com/vicentereig/dspy.rb/blob/main/lib/sorbet/toon/README.md
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.1'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 3.6.9
82
+ specification_version: 4
83
+ summary: TOON encode/decode pipeline for Sorbet signatures.
84
+ test_files: []