typemap 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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typemap
4
+ class Mapper
5
+ # Turns a type syntax tree (see {TypeParser}) into the {Type} the mapper checks, resolving
6
+ # constant names from the declaring namespace. Both type strategies end here, so they accept
7
+ # exactly the same subset; anything outside it raises "unsupported type".
8
+ #
9
+ # @api private
10
+ module TypeBuilder
11
+ # @param node [Array] the syntax tree
12
+ # @param namespace [String, nil] the declaring class's full name, for relative names
13
+ # @return [Object] a {Type}
14
+ # @raise [DefinitionError] on an unsupported type or an unknown constant
15
+ def self.build(node, namespace)
16
+ case node.first
17
+ when :optional then optional(build(node[1], namespace))
18
+ when :union then union(node[1], namespace)
19
+ when :literal then literals([node[1]])
20
+ when :bool then Type::Bool.new
21
+ when :nil then unsupported("nil alone")
22
+ when :untyped then unsupported("untyped; for plain data passed through as the file holds it, write Object")
23
+ when :name then named(node[1], node[2], namespace)
24
+ else raise ArgumentError, "not a type node: #{node.inspect}"
25
+ end
26
+ end
27
+
28
+ # @param inner [Object] a {Type}
29
+ # @return [Type::Optional]
30
+ def self.optional(inner)
31
+ inner.is_a?(Type::Optional) ? inner : Type::Optional.new(inner: inner)
32
+ end
33
+
34
+ # `"a" | "b"`, optionally with `nil` among them; a union of anything but literals is
35
+ # outside the subset.
36
+ def self.union(members, namespace)
37
+ nilable = members.any? { |member| member.first == :nil }
38
+ rest = members.reject { |member| member.first == :nil }
39
+ unsupported("a union of #{rest.size} types besides nil") if rest.empty?
40
+ built = rest.size == 1 ? build(rest.first, namespace) : literals(rest.map { |member| literal_value(member) })
41
+ nilable ? optional(built) : built
42
+ end
43
+
44
+ def self.literal_value(node)
45
+ unsupported("a union of anything but literals: #{node.inspect}") unless node.first == :literal
46
+ node[1]
47
+ end
48
+
49
+ def self.literals(values)
50
+ values.each do |value|
51
+ supported = [String, Integer, TrueClass, FalseClass].any? { |klass| value.is_a?(klass) }
52
+ unsupported("the literal #{value.inspect}: a file cannot hold one") unless supported
53
+ end
54
+ Type::Literals.new(values: values)
55
+ end
56
+
57
+ def self.named(name, args, namespace)
58
+ constant = NameResolver.resolve(name, namespace)
59
+ if [Integer, Float, String, Object].include?(constant)
60
+ unsupported("#{name}[…]") unless args.empty?
61
+ constant == Object ? Type::Plain.new : Type::Scalar.new(klass: constant)
62
+ elsif constant == Array
63
+ unsupported("Array without exactly one element type") unless args.size == 1
64
+ Type::ArrayOf.new(element: build(args.first, namespace))
65
+ elsif constant == Hash
66
+ hash_of(args, namespace)
67
+ elsif constant.is_a?(Class) && args.empty?
68
+ Type::ClassRef.new(klass: constant)
69
+ else
70
+ unsupported(args.empty? ? name : "#{name}[…]")
71
+ end
72
+ end
73
+
74
+ def self.hash_of(args, namespace)
75
+ unsupported("Hash without a key and a value type") unless args.size == 2
76
+ key = build(args.first, namespace)
77
+ unsupported("Hash[#{key}, …]: a key is always a String") unless key == Type::Scalar.new(klass: String)
78
+ Type::HashOf.new(value: build(args.last, namespace))
79
+ end
80
+
81
+ private_class_method :union, :literal_value, :literals, :named, :hash_of
82
+
83
+ # @raise [DefinitionError] always
84
+ def self.unsupported(what)
85
+ raise DefinitionError, "unsupported type: #{what}"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typemap
4
+ class Mapper
5
+ # Parses a type string in the subset of RBS the mapper supports into a syntax tree for
6
+ # {TypeBuilder}; `BasicTypes` uses it so the `rbs` gem stays optional. The grammar:
7
+ #
8
+ # type := postfix ('|' postfix)*
9
+ # postfix := primary '?'*
10
+ # primary := '(' type ')' | literal | 'bool' | 'nil' | 'untyped' | name ('[' type (',' type)* ']')?
11
+ # name := '::'? Constant ('::' Constant)*
12
+ # literal := "string" | 'string' | integer | true | false
13
+ #
14
+ # A tree node is an Array: `[:name, "Tuile::Size", [args]]`, `[:optional, node]`,
15
+ # `[:union, [nodes]]`, `[:literal, value]`, `[:bool]`, `[:nil]`, `[:untyped]` — the same nodes
16
+ # `RBSTypes` builds from the `rbs` gem's AST, so both strategies share one builder.
17
+ #
18
+ # @api private
19
+ class TypeParser
20
+ TOKEN = /\s*(?:(::)|([A-Za-z_]\w*)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(-?\d+(?:\.\d+)?)|([\[\],|?()]))/
21
+ private_constant :TOKEN
22
+
23
+ # @param source [String] the type as written, `"Array[String]?"`
24
+ # @return [Array] the syntax tree
25
+ # @raise [DefinitionError] on a syntax error
26
+ def self.parse(source)
27
+ new(source).parse
28
+ end
29
+
30
+ # @param source [String]
31
+ def initialize(source)
32
+ @source = source
33
+ @tokens = tokenize(source)
34
+ @pos = 0
35
+ end
36
+
37
+ # @return [Array] the syntax tree
38
+ # @raise [DefinitionError] on a syntax error
39
+ def parse
40
+ node = union
41
+ fail_at("unexpected #{peek.inspect}") if peek
42
+ node
43
+ end
44
+
45
+ private
46
+
47
+ def tokenize(source)
48
+ tokens = []
49
+ rest = source
50
+ until rest.strip.empty?
51
+ match = TOKEN.match(rest)
52
+ unless match&.begin(0)&.zero?
53
+ raise DefinitionError,
54
+ "type #{source.inspect}: cannot read #{rest.strip.inspect}"
55
+ end
56
+
57
+ tokens << match.captures.compact.first
58
+ rest = match.post_match
59
+ end
60
+ tokens
61
+ end
62
+
63
+ def peek = @tokens[@pos]
64
+
65
+ def take
66
+ @tokens[@pos].tap { @pos += 1 }
67
+ end
68
+
69
+ def expect(token)
70
+ fail_at("expected #{token.inspect}, got #{peek.inspect}") unless peek == token
71
+ take
72
+ end
73
+
74
+ def fail_at(detail)
75
+ raise DefinitionError, "type #{@source.inspect}: #{detail}"
76
+ end
77
+
78
+ def union
79
+ members = [postfix]
80
+ while peek == "|"
81
+ take
82
+ members << postfix
83
+ end
84
+ members.size == 1 ? members.first : [:union, members]
85
+ end
86
+
87
+ def postfix
88
+ node = primary
89
+ while peek == "?"
90
+ take
91
+ node = [:optional, node] unless node.first == :optional
92
+ end
93
+ node
94
+ end
95
+
96
+ def primary
97
+ token = peek
98
+ fail_at("unexpected end") if token.nil?
99
+ return parenthesized if token == "("
100
+ return [:literal, literal(take)] if token.match?(/\A(?:["'-]|\d)/)
101
+
102
+ case token
103
+ when "bool" then take && [:bool]
104
+ when "nil" then take && [:nil]
105
+ when "untyped" then take && [:untyped]
106
+ when "true" then take && [:literal, true]
107
+ when "false" then take && [:literal, false]
108
+ else name
109
+ end
110
+ end
111
+
112
+ def parenthesized
113
+ expect("(")
114
+ node = union
115
+ expect(")")
116
+ node
117
+ end
118
+
119
+ def literal(token)
120
+ case token
121
+ when /\A"/ then token[1..-2].gsub(/\\(.)/, '\1')
122
+ when /\A'/ then token[1..-2].gsub(/\\(.)/, '\1')
123
+ when /\./ then fail_at("a Float literal is not a type in RBS: #{token}")
124
+ else Integer(token)
125
+ end
126
+ end
127
+
128
+ def name
129
+ parts = []
130
+ absolute = peek == "::"
131
+ take if absolute
132
+ loop do
133
+ part = take
134
+ fail_at("expected a constant, got #{part.inspect}") unless part&.match?(/\A[A-Z]/)
135
+ parts << part
136
+ break unless peek == "::"
137
+
138
+ take
139
+ end
140
+ [:name, "#{"::" if absolute}#{parts.join("::")}", arguments]
141
+ end
142
+
143
+ def arguments
144
+ return [] unless peek == "["
145
+
146
+ take
147
+ args = [union]
148
+ while peek == ","
149
+ take
150
+ args << union
151
+ end
152
+ expect("]")
153
+ args
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typemap
4
+ class Mapper
5
+ # How a load error words the found value and the fix, for the source the app names in
6
+ # `load(..., source:)`. The types detect the mistake in the Hash; a wording only spells it:
7
+ #
8
+ # :ruby server: expected Array[Server], got a single Server; wrap it in an array
9
+ # :toml server: expected Array[Server], got a single Server; write [[server]], not [server], or server = [{…}]
10
+ #
11
+ # Each source is a module answering the same six calls: `found(value)`, `wrap(path, entry)`,
12
+ # `unwrap(path)`, `quote(value)`, `unquote` and `misplaced(key, path, owner)`. A hint returns
13
+ # `nil` for none. A dump error is worded for {Ruby}: its reader is the programmer.
14
+ #
15
+ # @api private
16
+ module Wording
17
+ # @param source [Symbol] `:ruby`, `:toml`, `:json` or `:yaml`
18
+ # @return [Module] the wording for it
19
+ # @raise [ArgumentError] on any other source
20
+ def self.for(source)
21
+ case source
22
+ when :ruby then Ruby
23
+ when :toml then TOML
24
+ when :json then JSON
25
+ when :yaml then YAML
26
+ else raise ArgumentError, "source: must be :ruby, :toml, :json or :yaml, got #{source.inspect}"
27
+ end
28
+ end
29
+
30
+ # @param value [Object]
31
+ # @return [String] its `inspect`, cut at 60 characters
32
+ def self.inspected(value)
33
+ text = value.inspect
34
+ text.length > 61 ? "#{text[0, 60]}…" : text
35
+ end
36
+
37
+ # @param key [String]
38
+ # @param owner [String] the enclosing object's path, `""` for the top level
39
+ # @return [String] `sandbox belongs to the top level`
40
+ def self.belongs(key, owner) = "#{key} belongs to #{owner.empty? ? "the top level" : owner}"
41
+
42
+ QUOTE = "quote it to make it a string"
43
+ UNQUOTE = "remove the quotes"
44
+
45
+ # A Hash built in Ruby: the found value's class names the mistake, and there are no quotes
46
+ # to add or remove.
47
+ #
48
+ # value: expected String, got Integer 70
49
+ module Ruby
50
+ # @param value [Object]
51
+ # @return [String] `Integer 70`; bare for `nil`, `true`, `false` and an inspect that names
52
+ # its class already, `#<data Server …>`
53
+ def self.found(value)
54
+ text = Wording.inspected(value)
55
+ [nil, true, false].include?(value) || text.start_with?("#<") ? text : "#{value.class} #{text}"
56
+ end
57
+
58
+ # @param _path [String]
59
+ # @param _entry [Boolean]
60
+ # @return [String]
61
+ def self.wrap(_path, _entry) = "wrap it in an array"
62
+
63
+ # @param _path [String]
64
+ # @return [String]
65
+ def self.unwrap(_path) = "unwrap it"
66
+
67
+ # @param _value [Object]
68
+ # @return [nil]
69
+ def self.quote(_value) = nil
70
+
71
+ # @return [nil]
72
+ def self.unquote = nil
73
+
74
+ # @param key [String]
75
+ # @param _path [String]
76
+ # @param owner [String]
77
+ # @return [String]
78
+ def self.misplaced(key, _path, owner) = Wording.belongs(key, owner)
79
+ end
80
+
81
+ # TOML: headers spelled from the path, `llm[0].servers` → `[[llm.servers]]`, since a header
82
+ # names no index.
83
+ #
84
+ # == Implementation details
85
+ #
86
+ # An array hint names the inline spelling too, `server = [{…}]`: Typemap can't tell which
87
+ # one the file used.
88
+ module TOML
89
+ # @param value [Object]
90
+ # @return [String]
91
+ def self.found(value) = Wording.inspected(value)
92
+
93
+ # @param path [String]
94
+ # @param entry [Boolean] whether the lone value is an object
95
+ # @return [String]
96
+ def self.wrap(path, entry)
97
+ return "wrap it in [ ]" unless entry && keyed?(path)
98
+
99
+ "write #{header(path, array: true)}, not #{header(path)}, or #{key(path)} = [{…}]"
100
+ end
101
+
102
+ # @param path [String]
103
+ # @return [String]
104
+ def self.unwrap(path)
105
+ return "remove the [ ]" unless keyed?(path)
106
+
107
+ "write #{header(path)}, not #{header(path, array: true)}, or #{key(path)} = {…}"
108
+ end
109
+
110
+ # @param _value [Object]
111
+ # @return [String]
112
+ def self.quote(_value) = QUOTE
113
+
114
+ # @return [String]
115
+ def self.unquote = UNQUOTE
116
+
117
+ # @param key [String]
118
+ # @param path [String] the object the key sits in
119
+ # @param owner [String] the enclosing object that has the key, `""` for the top level
120
+ # @return [String] `sandbox belongs to code, under [code] before [code.panel]`
121
+ def self.misplaced(key, path, owner)
122
+ where = owner.empty? ? "before any [header]" : "under #{header(owner)} before #{header(path)}"
123
+ "#{Wording.belongs(key, owner)}, #{where}"
124
+ end
125
+
126
+ # @param path [String] `llm[0].params`
127
+ # @param array [Boolean] whether to spell it as an array entry's header
128
+ # @return [String] `[llm.params]`; `[[llm]]` for `llm[0]`
129
+ def self.header(path, array: path.end_with?("]"))
130
+ name = path.split(".").map { |key| bare(key.sub(/\[\d+\]\z/, "")) }.join(".")
131
+ array ? "[[#{name}]]" : "[#{name}]"
132
+ end
133
+
134
+ # @param path [String]
135
+ # @return [Boolean] whether the value sits under a key, which a header can name — not at the
136
+ # root, nor directly in an array
137
+ def self.keyed?(path) = !path.empty? && !path.end_with?("]")
138
+
139
+ # @param path [String] `llm[0].servers`
140
+ # @return [String] `servers`, as TOML writes it
141
+ def self.key(path) = bare(path.split(".").last)
142
+
143
+ # @param key [String]
144
+ # @return [String] the key as TOML writes it, quoted unless bare
145
+ def self.bare(key) = key.match?(/\A[A-Za-z0-9_-]+\z/) ? key : key.inspect
146
+ private_class_method :header, :keyed?, :key, :bare
147
+ end
148
+
149
+ # JSON: brackets and braces.
150
+ module JSON
151
+ # @param value [Object]
152
+ # @return [String]
153
+ def self.found(value) = Wording.inspected(value)
154
+
155
+ # @param _path [String]
156
+ # @param _entry [Boolean]
157
+ # @return [String]
158
+ def self.wrap(_path, _entry) = "wrap it in [ ]"
159
+
160
+ # @param _path [String]
161
+ # @return [String]
162
+ def self.unwrap(_path) = "remove the [ ]"
163
+
164
+ # @param _value [Object]
165
+ # @return [String]
166
+ def self.quote(_value) = QUOTE
167
+
168
+ # @return [String]
169
+ def self.unquote = UNQUOTE
170
+
171
+ # @param key [String]
172
+ # @param path [String] the object the key sits in
173
+ # @param owner [String] the enclosing object that has the key, `""` for the top level
174
+ # @return [String] `sandbox belongs to the top level; move it out of pane's { }`
175
+ def self.misplaced(key, path, owner) = "#{Wording.belongs(key, owner)}; move it out of #{path}'s { }"
176
+ end
177
+
178
+ # YAML in block style: `- ` and indentation.
179
+ module YAML
180
+ # @param value [Object]
181
+ # @return [String]
182
+ def self.found(value) = Wording.inspected(value)
183
+
184
+ # @param _path [String]
185
+ # @param _entry [Boolean]
186
+ # @return [String]
187
+ def self.wrap(_path, _entry) = 'put "- " before it'
188
+
189
+ # @param _path [String]
190
+ # @return [String]
191
+ def self.unwrap(_path) = 'remove the "- "'
192
+
193
+ # Psych reads YAML 1.1, so `no` loads as `false`: the Norway problem.
194
+ #
195
+ # @param value [Object]
196
+ # @return [String]
197
+ def self.quote(value)
198
+ [true, false].include?(value) ? "#{QUOTE}; YAML reads yes, no, on and off as booleans" : QUOTE
199
+ end
200
+
201
+ # @return [String]
202
+ def self.unquote = UNQUOTE
203
+
204
+ # @param key [String]
205
+ # @param _path [String]
206
+ # @param owner [String]
207
+ # @return [String] `sandbox belongs to the top level; dedent it`
208
+ def self.misplaced(key, _path, owner) = "#{Wording.belongs(key, owner)}; dedent it"
209
+ end
210
+ end
211
+ end
212
+ end