json5-ruby 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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON5
4
+ class LineMap
5
+ def initialize(source)
6
+ Input.new(source, limits: Limits.unbounded)
7
+ @source = source
8
+ @line_starts = build_line_starts.freeze
9
+ freeze
10
+ end
11
+
12
+ def position(byte_offset)
13
+ line, column = line_column(byte_offset)
14
+ Input::Position.new(byte_offset, line, column)
15
+ end
16
+
17
+ def line_column(byte_offset)
18
+ validate_byte_offset!(byte_offset)
19
+
20
+ next_line = @line_starts.bsearch_index { |start_byte| start_byte > byte_offset }
21
+ if crlf_middle?(byte_offset)
22
+ return [next_line + 1, 1]
23
+ end
24
+
25
+ line_index = (next_line || @line_starts.length) - 1
26
+ [line_index + 1, column_at(@line_starts.fetch(line_index), byte_offset)]
27
+ end
28
+
29
+ private
30
+
31
+ def build_line_starts
32
+ starts = [0]
33
+ index = 0
34
+ while index < @source.bytesize
35
+ byte = @source.getbyte(index)
36
+ if byte == 0x0d
37
+ index += @source.getbyte(index + 1) == 0x0a ? 2 : 1
38
+ starts << index
39
+ elsif byte == 0x0a
40
+ index += 1
41
+ starts << index
42
+ elsif unicode_line_separator_at?(index)
43
+ index += 3
44
+ starts << index
45
+ else
46
+ index += utf8_width(byte)
47
+ end
48
+ end
49
+ starts
50
+ end
51
+
52
+ def column_at(line_start, byte_offset)
53
+ column = 1
54
+ index = line_start
55
+ while index < byte_offset
56
+ width = utf8_width(@source.getbyte(index))
57
+ break if index + width > byte_offset
58
+
59
+ index += width
60
+ column += 1
61
+ end
62
+ column
63
+ end
64
+
65
+ def crlf_middle?(byte_offset)
66
+ byte_offset.positive? &&
67
+ @source.getbyte(byte_offset) == 0x0a &&
68
+ @source.getbyte(byte_offset - 1) == 0x0d
69
+ end
70
+
71
+ def unicode_line_separator_at?(index)
72
+ @source.getbyte(index) == 0xe2 &&
73
+ @source.getbyte(index + 1) == 0x80 &&
74
+ [0xa8, 0xa9].include?(@source.getbyte(index + 2))
75
+ end
76
+
77
+ def utf8_width(byte)
78
+ return 1 if byte < 0x80
79
+ return 2 if byte < 0xe0
80
+ return 3 if byte < 0xf0
81
+
82
+ 4
83
+ end
84
+
85
+ def validate_byte_offset!(byte_offset)
86
+ raise TypeError, "byte offset must be an Integer" unless byte_offset.is_a?(Integer)
87
+ return if byte_offset.between?(0, @source.bytesize)
88
+
89
+ raise RangeError, "byte offset is outside the source"
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON5
4
+ class NumberValue
5
+ attr_reader :raw, :kind
6
+
7
+ def initialize(raw, kind: nil)
8
+ @raw = raw.to_s.freeze
9
+ @kind = (kind || classify(@raw)).to_sym
10
+ freeze
11
+ end
12
+
13
+ def to_native
14
+ case kind
15
+ when :infinity
16
+ raw.start_with?("-") ? -Float::INFINITY : Float::INFINITY
17
+ when :nan
18
+ Float::NAN
19
+ when :hex
20
+ sign = raw.start_with?("-") ? -1 : 1
21
+ sign * Integer(raw.delete_prefix("+").delete_prefix("-"), 16)
22
+ when :integer
23
+ return -0.0 if raw == "-0"
24
+ Integer(raw, 10)
25
+ else
26
+ decimal_to_float
27
+ end
28
+ end
29
+
30
+ def to_f
31
+ return hex_to_f if kind == :hex
32
+
33
+ to_native.to_f
34
+ end
35
+
36
+ private
37
+
38
+ def decimal_to_float
39
+ normalized = raw.dup
40
+ body_start = normalized.start_with?("+", "-") ? 1 : 0
41
+ normalized.insert(body_start, "0") if normalized.getbyte(body_start) == 0x2e
42
+ decimal_point = normalized.index(".", body_start)
43
+ if decimal_point && [nil, 0x45, 0x65].include?(normalized.getbyte(decimal_point + 1))
44
+ normalized.insert(decimal_point + 1, "0")
45
+ end
46
+ Float(normalized)
47
+ end
48
+
49
+ def classify(value)
50
+ body = value.delete_prefix("+").delete_prefix("-")
51
+ return :infinity if body == "Infinity"
52
+ return :nan if body == "NaN"
53
+ return :hex if body.start_with?("0x", "0X")
54
+ return :integer if !body.include?(".") && !body.include?("e") && !body.include?("E")
55
+
56
+ :float
57
+ end
58
+
59
+ def hex_to_f
60
+ negative = raw.start_with?("-")
61
+ first_byte = raw.getbyte(0)
62
+ digits_start = first_byte == 0x2b || first_byte == 0x2d ? 3 : 2
63
+ digit_count = raw.bytesize - digits_start
64
+ first_nonzero = 0
65
+ first_nonzero += 1 while first_nonzero < digit_count && raw.getbyte(digits_start + first_nonzero) == 0x30
66
+ return negative ? -0.0 : 0.0 if first_nonzero == digit_count
67
+
68
+ first_digit = raw.getbyte(digits_start + first_nonzero)
69
+ first_digit -= 0x30 if first_digit <= 0x39
70
+ first_digit -= 0x37 if first_digit >= 0x41 && first_digit <= 0x46
71
+ first_digit -= 0x57 if first_digit >= 0x61 && first_digit <= 0x66
72
+ leading_bits = first_digit.bit_length
73
+ first_bit = first_nonzero * 4 + (4 - leading_bits)
74
+ bit_length = digit_count * 4 - first_bit
75
+ exponent = bit_length - 1
76
+ return negative ? -Float::INFINITY : Float::INFINITY if exponent > 1023
77
+
78
+ significand = 0
79
+ [bit_length, 53].min.times do |index|
80
+ significand = (significand << 1) | hex_bit(digits_start, first_bit + index)
81
+ end
82
+ if bit_length > 53
83
+ guard = hex_bit(digits_start, first_bit + 53)
84
+ sticky = nonzero_hex_bit?(digits_start, first_bit + 54, digit_count * 4)
85
+ significand += 1 if guard == 1 && (sticky || significand.odd?)
86
+ if significand == (1 << 53)
87
+ significand >>= 1
88
+ exponent += 1
89
+ return negative ? -Float::INFINITY : Float::INFINITY if exponent > 1023
90
+ end
91
+ else
92
+ significand <<= (53 - bit_length)
93
+ end
94
+
95
+ result = significand.to_f * (2.0**(exponent - 52))
96
+ negative ? -result : result
97
+ end
98
+
99
+ def nonzero_hex_bit?(digits_start, bit_index, end_bit)
100
+ while bit_index < end_bit
101
+ return true if hex_bit(digits_start, bit_index) == 1
102
+ bit_index += 1
103
+ end
104
+ false
105
+ end
106
+
107
+ def hex_bit(digits_start, bit_index)
108
+ byte = raw.getbyte(digits_start + (bit_index / 4))
109
+ nibble = if byte <= 0x39
110
+ byte - 0x30
111
+ elsif byte <= 0x46
112
+ byte - 0x37
113
+ else
114
+ byte - 0x57
115
+ end
116
+ (nibble >> (3 - (bit_index % 4))) & 1
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON5
4
+ class ObjectValue
5
+ Member = Data.define(:key, :value)
6
+
7
+ attr_reader :members
8
+
9
+ def initialize(members)
10
+ @members = members.map { |key, value| Member.new(key, value) }.freeze
11
+ freeze
12
+ end
13
+
14
+ def [](key)
15
+ member = members.reverse_each.find { |candidate| candidate.key == key }
16
+ member&.value
17
+ end
18
+
19
+ def each(&block)
20
+ return enum_for(__method__) unless block
21
+ members.each(&block)
22
+ end
23
+
24
+ def to_h
25
+ members.to_h { |member| [member.key, member.value] }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON5
4
+ class Parser
5
+ DEFAULT_OPTIONS = {
6
+ duplicate_keys: :last,
7
+ number_mode: :native,
8
+ string_mode: :ruby,
9
+ lone_surrogate: :error,
10
+ diagnostics: nil,
11
+ limits: Limits.default,
12
+ filename: nil,
13
+ freeze: false,
14
+ warn_duplicate_keys: false,
15
+ preserve_trivia: true,
16
+ borrow_source: false
17
+ }.freeze
18
+ PARSE_OPTION_KEYS = (DEFAULT_OPTIONS.keys - %i[preserve_trivia borrow_source]).freeze
19
+ DOCUMENT_OPTION_KEYS = %i[diagnostics limits filename preserve_trivia borrow_source].freeze
20
+
21
+ def self.parse(source, **options)
22
+ new(source, **options).parse
23
+ end
24
+
25
+ def self.load(io, **options)
26
+ unless io.respond_to?(:read)
27
+ raise TypeError, "io must respond to #read"
28
+ end
29
+ validate_option_keys!(options, PARSE_OPTION_KEYS)
30
+ validate_parse_options!(options)
31
+ limits = options.fetch(:limits, Limits.default)
32
+ source = read_source(io, limits, filename: options.fetch(:filename, nil))
33
+ parse(source, **options)
34
+ end
35
+
36
+ def self.parse_document(source, **options)
37
+ validate_option_keys!(options, DOCUMENT_OPTION_KEYS)
38
+ merged = DEFAULT_OPTIONS.merge(options)
39
+ validate_limits_option!(merged.fetch(:limits))
40
+ validate_diagnostics_option!(merged.fetch(:diagnostics))
41
+ validate_boolean_option!(merged.fetch(:preserve_trivia), :preserve_trivia)
42
+ validate_boolean_option!(merged.fetch(:borrow_source), :borrow_source)
43
+ unless source.is_a?(String)
44
+ raise TypeError, "source must be a String"
45
+ end
46
+ if merged[:limits].max_input_bytes && source.bytesize > merged[:limits].max_input_bytes
47
+ Input.new(source, limits: merged.fetch(:limits), filename: merged.fetch(:filename))
48
+ end
49
+ owned_source = merged.fetch(:borrow_source) ? source : source.dup.freeze
50
+ collected_diagnostics = []
51
+ diagnostics = merged.fetch(:diagnostics)
52
+ diagnostic_target = case diagnostics
53
+ when Array
54
+ ->(diagnostic) { collected_diagnostics << diagnostic; diagnostics << diagnostic }
55
+ when :ignore
56
+ :ignore
57
+ else
58
+ if diagnostics.respond_to?(:call)
59
+ ->(diagnostic) { collected_diagnostics << diagnostic; diagnostics.call(diagnostic) }
60
+ else
61
+ lambda do |diagnostic|
62
+ collected_diagnostics << diagnostic
63
+ DiagnosticSink.write_default_warning(diagnostic)
64
+ end
65
+ end
66
+ end
67
+ DocumentParser.new(
68
+ owned_source,
69
+ limits: merged.fetch(:limits),
70
+ diagnostics: diagnostic_target,
71
+ filename: merged.fetch(:filename),
72
+ preserve_trivia: merged.fetch(:preserve_trivia),
73
+ collected_diagnostics: collected_diagnostics
74
+ ).parse
75
+ end
76
+
77
+ def self.read_source(io, limits, filename: nil)
78
+ external_encoding = io.external_encoding if io.respond_to?(:external_encoding)
79
+ external_encoding = nil unless [Encoding::UTF_8, Encoding::US_ASCII].include?(external_encoding)
80
+ allow_binary_chunks = !external_encoding.nil?
81
+ unless limits.max_input_bytes
82
+ source = io.read
83
+ validate_read_chunk!(source, filename: filename, byte_offset: 0, preceding: nil,
84
+ allow_binary: allow_binary_chunks)
85
+ return normalize_read_encoding(source, external_encoding)
86
+ end
87
+
88
+ source = String.new(encoding: Encoding::BINARY)
89
+ source_encoding = external_encoding
90
+ chunk_size = [limits.max_input_bytes + 1, 16 * 1024].min
91
+ while source.bytesize <= limits.max_input_bytes
92
+ chunk = io.read(chunk_size)
93
+ break if chunk.nil?
94
+ validate_read_chunk!(chunk, filename: filename, byte_offset: source.bytesize, preceding: source,
95
+ allow_binary: allow_binary_chunks)
96
+ break if chunk.empty?
97
+ source_encoding = merged_read_encoding(source_encoding, chunk.encoding)
98
+ remaining = limits.max_input_bytes + 1 - source.bytesize
99
+ if chunk.bytesize >= remaining
100
+ position_bytes = source + chunk.b.byteslice(0, remaining + 3).to_s
101
+ position_bytes.force_encoding(source_encoding || Encoding::UTF_8)
102
+ line, column = Input.position_for(position_bytes, limits.max_input_bytes)
103
+ raise LimitError.new(
104
+ "input exceeds max_input_bytes",
105
+ code: "maximum_input_size_exceeded",
106
+ filename: filename,
107
+ byte_offset: limits.max_input_bytes,
108
+ end_byte_offset: limits.max_input_bytes + 1,
109
+ line: line,
110
+ column: column,
111
+ excerpt: limit_excerpt(source, chunk, limits.max_input_bytes)
112
+ )
113
+ end
114
+ source << chunk.b
115
+ end
116
+ source.force_encoding(source_encoding || Encoding::UTF_8)
117
+ end
118
+
119
+ def self.normalize_read_encoding(source, external_encoding)
120
+ return source unless source.encoding == Encoding::ASCII_8BIT && external_encoding
121
+
122
+ source.dup.force_encoding(external_encoding)
123
+ end
124
+
125
+ def self.merged_read_encoding(current, chunk_encoding)
126
+ return current if chunk_encoding == Encoding::ASCII_8BIT
127
+ return Encoding::UTF_8 if current == Encoding::UTF_8 || chunk_encoding == Encoding::UTF_8
128
+
129
+ current || chunk_encoding
130
+ end
131
+
132
+ def self.validate_read_chunk!(chunk, filename:, byte_offset:, preceding:, allow_binary: false)
133
+ raise TypeError, "io#read must return a String" unless chunk.is_a?(String)
134
+ return if [Encoding::UTF_8, Encoding::US_ASCII].include?(chunk.encoding) ||
135
+ (allow_binary && chunk.encoding == Encoding::ASCII_8BIT)
136
+
137
+ line, column = Input.position_for(preceding || chunk, byte_offset)
138
+ raise EncodingError.new(
139
+ "source must be UTF-8 or US-ASCII",
140
+ code: "invalid_encoding",
141
+ filename: filename,
142
+ byte_offset: byte_offset,
143
+ end_byte_offset: byte_offset + [chunk.bytesize, 1].min,
144
+ line: line,
145
+ column: column,
146
+ excerpt: limit_excerpt(preceding || String.new(encoding: Encoding::BINARY), chunk, byte_offset)
147
+ )
148
+ end
149
+
150
+ def self.validate_parse_options!(options)
151
+ merged = DEFAULT_OPTIONS.merge(options)
152
+ validate_limits_option!(merged.fetch(:limits))
153
+ validate_diagnostics_option!(merged.fetch(:diagnostics))
154
+ validate_boolean_option!(merged.fetch(:freeze), :freeze)
155
+ validate_boolean_option!(merged.fetch(:warn_duplicate_keys), :warn_duplicate_keys)
156
+ validate_enum_option!(RubyValueBuilder::VALID_DUPLICATE_POLICIES, merged.fetch(:duplicate_keys), :duplicate_keys)
157
+ validate_enum_option!(RubyValueBuilder::VALID_NUMBER_MODES, merged.fetch(:number_mode), :number_mode)
158
+ validate_enum_option!(RubyValueBuilder::VALID_STRING_MODES, merged.fetch(:string_mode), :string_mode)
159
+ validate_enum_option!([:error, :replace], merged.fetch(:lone_surrogate), :lone_surrogate)
160
+ end
161
+
162
+ def self.limit_excerpt(source, chunk, byte_offset, radius: 40)
163
+ start_byte = [byte_offset - radius, 0].max
164
+ finish_byte = byte_offset + 1
165
+ excerpt = String.new(encoding: Encoding::BINARY)
166
+ if start_byte < source.bytesize
167
+ excerpt << source.byteslice(start_byte...[finish_byte, source.bytesize].min).to_s.b
168
+ end
169
+ chunk_start = [start_byte - source.bytesize, 0].max
170
+ chunk_finish = [finish_byte - source.bytesize, 0].max
171
+ excerpt << chunk.byteslice(chunk_start...chunk_finish).to_s.b
172
+ excerpt.force_encoding(Encoding::UTF_8).scrub
173
+ end
174
+
175
+ def initialize(source, **options)
176
+ self.class.send(:validate_option_keys!, options, PARSE_OPTION_KEYS)
177
+ @options = DEFAULT_OPTIONS.merge(options)
178
+ validate_options
179
+ @input = Input.new(
180
+ source,
181
+ limits: @options.fetch(:limits),
182
+ filename: @options.fetch(:filename)
183
+ )
184
+ end
185
+
186
+ def parse
187
+ builder = RubyValueBuilder.new(
188
+ number_mode: @options.fetch(:number_mode),
189
+ string_mode: @options.fetch(:string_mode),
190
+ lone_surrogate: @options.fetch(:lone_surrogate),
191
+ duplicate_keys: @options.fetch(:duplicate_keys),
192
+ limits: @options.fetch(:limits),
193
+ diagnostics: @options.fetch(:diagnostics),
194
+ warn_duplicate_keys: @options.fetch(:warn_duplicate_keys),
195
+ freeze_values: @options.fetch(:freeze),
196
+ source: @input.source,
197
+ filename: @options.fetch(:filename)
198
+ )
199
+ lexer = Lexer.new(
200
+ @input,
201
+ limits: @options.fetch(:limits),
202
+ diagnostics: @options.fetch(:diagnostics),
203
+ filename: @options.fetch(:filename)
204
+ )
205
+ @lexer = lexer
206
+ generated = GeneratedParser.new(lexer, builder)
207
+ generated.parse_tokens
208
+ rescue JSON5::Error => error
209
+ raise enrich_error(error)
210
+ rescue Ibex::Runtime::ParseError => error
211
+ raise enrich_error(translate_parse_error(error))
212
+ end
213
+
214
+ private
215
+
216
+ def validate_options
217
+ self.class.send(:validate_parse_options!, @options)
218
+ end
219
+
220
+ def self.validate_option_keys!(options, allowed_keys)
221
+ unknown_options = options.keys - allowed_keys
222
+ return if unknown_options.empty?
223
+
224
+ raise ArgumentError, "unknown options: #{unknown_options.map(&:to_s).sort.join(', ')}"
225
+ end
226
+
227
+ def self.validate_limits_option!(limits)
228
+ return if limits.is_a?(Limits)
229
+
230
+ raise ArgumentError, "limits must be a JSON5::Limits"
231
+ end
232
+
233
+ def self.validate_diagnostics_option!(diagnostics)
234
+ return if diagnostics.nil? || diagnostics == :ignore || diagnostics.is_a?(Array) || diagnostics.respond_to?(:call)
235
+
236
+ raise ArgumentError, "diagnostics must be an Array, callable, :ignore, or nil"
237
+ end
238
+
239
+ def self.validate_boolean_option!(value, name)
240
+ return if value == true || value == false
241
+
242
+ raise ArgumentError, "#{name} must be true or false"
243
+ end
244
+
245
+ def self.validate_enum_option!(allowed, value, name)
246
+ return if allowed.include?(value)
247
+
248
+ raise ArgumentError, "#{name} must be one of #{allowed.inspect}"
249
+ end
250
+
251
+ def translate_parse_error(error)
252
+ internal_expected = error.respond_to?(:expected_tokens) ? contextual_expected(error.expected_tokens) : nil
253
+ internal_unexpected = error.respond_to?(:token_name) ? error.token_name : nil
254
+ trailing_tokens = internal_unexpected != "$eof" && internal_expected&.include?("$eof") &&
255
+ @lexer&.last_token_nesting_depth == 0
256
+ expected = trailing_tokens ? ["end of input"] : PublicTokenNames.expected(internal_expected)
257
+ unexpected = PublicTokenNames.name(internal_unexpected || "token")
258
+ token = @lexer&.last_token
259
+ message = "unexpected #{unexpected}"
260
+ message += "; expected #{expected.join(', ')}" if expected && !expected.empty?
261
+ ParseError.new(
262
+ message,
263
+ code: trailing_tokens ? "trailing_tokens" : "unexpected_token",
264
+ filename: @options.fetch(:filename),
265
+ byte_offset: token&.start_byte || @input.index,
266
+ end_byte_offset: token&.end_byte || @input.index,
267
+ line: token&.line || @input.line,
268
+ column: token&.column || @input.column,
269
+ unexpected: unexpected,
270
+ expected: expected
271
+ )
272
+ end
273
+
274
+ def contextual_expected(expected_tokens)
275
+ expected = Array(expected_tokens).dup
276
+ case @lexer&.last_token_container
277
+ when "{"
278
+ expected -= ["$eof", "']'"]
279
+ when "["
280
+ expected -= ["$eof", "'}'"]
281
+ end
282
+ expected
283
+ end
284
+
285
+ def enrich_error(error)
286
+ return error if error.byte_offset && error.end_byte_offset && error.line && error.column && error.excerpt
287
+
288
+ token = @lexer&.last_token
289
+ offset = error.byte_offset || token&.start_byte || @input.index
290
+ finish = error.end_byte_offset || token&.end_byte || @input.index
291
+ error.with_context(
292
+ filename: error.filename || @options.fetch(:filename),
293
+ byte_offset: offset,
294
+ end_byte_offset: finish,
295
+ line: error.line || token&.line || @input.line,
296
+ column: error.column || token&.column || @input.column,
297
+ excerpt: error.excerpt || @input.excerpt_at(offset)
298
+ )
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSON5
4
+ module PublicTokenNames
5
+ VALUE_TOKENS = ["STRING", "NUMBER", "TRUE", "FALSE", "NULL", "INFINITY", "NAN", "'{'", "'['"].freeze
6
+ MEMBER_NAME_TOKENS = %w[STRING IDENTIFIER_NAME TRUE FALSE NULL INFINITY NAN].freeze
7
+ NAMES = {
8
+ "$eof" => "end of input",
9
+ "EOF" => "end of input",
10
+ "STRING" => "string",
11
+ "NUMBER" => "number",
12
+ "IDENTIFIER_NAME" => "identifier",
13
+ "TRUE" => "true",
14
+ "FALSE" => "false",
15
+ "NULL" => "null",
16
+ "INFINITY" => "Infinity",
17
+ "NAN" => "NaN"
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ def name(token_name)
23
+ internal_name = token_name.to_s
24
+ return NAMES.fetch(internal_name) if NAMES.key?(internal_name)
25
+ return internal_name[1...-1] if internal_name.start_with?("'") && internal_name.end_with?("'")
26
+
27
+ internal_name.downcase.tr("_", " ")
28
+ end
29
+
30
+ def expected(token_names)
31
+ remaining = Array(token_names).map(&:to_s)
32
+ public_names = []
33
+ if (VALUE_TOKENS - remaining).empty?
34
+ public_names << "value"
35
+ remaining -= VALUE_TOKENS
36
+ elsif (MEMBER_NAME_TOKENS - remaining).empty?
37
+ public_names << "member name"
38
+ remaining -= MEMBER_NAME_TOKENS
39
+ end
40
+ public_names.concat(remaining.map { |token_name| name(token_name) }).uniq.freeze
41
+ end
42
+ end
43
+ end