-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
/
Copy pathbuilder.cr
420 lines (367 loc) · 9.21 KB
/
builder.cr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
# A JSON builder generates valid JSON.
#
# A `JSON::Error` is raised if attempting to generate an invalid JSON
# (for example, if invoking `end_array` without a matching `start_array`,
# or trying to use a non-string value as an object's field name).
class JSON::Builder
private getter io
record StartState
record DocumentStartState
record ArrayState, empty : Bool
record ObjectState, empty : Bool, name : Bool
record DocumentEndState
alias State = StartState | DocumentStartState | ArrayState | ObjectState | DocumentEndState
@indent : String?
# By default the maximum nesting of arrays/objects is 99. Nesting more
# than this will result in a JSON::Error. Changing the value of this property
# allows more/less nesting.
property max_nesting = 99
# Creates a `JSON::Builder` that will write to the given `IO`.
def initialize(@io : IO)
@state = [StartState.new] of State
@current_indent = 0
end
# Starts a document.
def start_document
case state = @state.last
when StartState
@state[-1] = DocumentStartState.new
when DocumentEndState
@state[-1] = DocumentStartState.new
else
raise JSON::Error.new("Starting document before ending previous one")
end
end
# Signals the end of a JSON document.
def end_document : Nil
case state = @state.last
when StartState
raise JSON::Error.new("Empty JSON")
when DocumentStartState
raise JSON::Error.new("Empty JSON")
when ArrayState
raise JSON::Error.new("Unterminated JSON array")
when ObjectState
raise JSON::Error.new("Unterminated JSON object")
when DocumentEndState
# okay
end
@io.flush
end
def document
start_document
yield.tap { end_document }
end
# Writes a `null` value.
def null
scalar do
@io << "null"
end
end
# Writes a boolean value.
def bool(value : Bool)
scalar do
@io << value
end
end
# Writes an integer.
def number(number : Int)
scalar do
@io << number
end
end
# Writes a float.
def number(number : Float)
scalar do
case number
when .nan?
raise JSON::Error.new("NaN not allowed in JSON")
when .infinite?
raise JSON::Error.new("Infinity not allowed in JSON")
else
@io << number
end
end
end
# Writes a string. The given *value* is first converted to a `String`
# by invoking `to_s` on it.
#
# This method can also be used to write the name of an object field.
def string(value)
string = value.to_s
scalar(string: true) do
io << '"'
start_pos = 0
reader = Char::Reader.new(string)
while reader.has_next?
case char = reader.current_char
when '\\'
escape = "\\\\"
when '"'
escape = "\\\""
when '\b'
escape = "\\b"
when '\f'
escape = "\\f"
when '\n'
escape = "\\n"
when '\r'
escape = "\\r"
when '\t'
escape = "\\t"
when .ascii_control?
io.write string.to_slice[start_pos, reader.pos - start_pos]
io << "\\u"
ord = char.ord
io << '0' if ord < 0x1000
io << '0' if ord < 0x100
io << '0' if ord < 0x10
ord.to_s(io, 16)
reader.next_char
start_pos = reader.pos
next
else
reader.next_char
next
end
io.write string.to_slice[start_pos, reader.pos - start_pos]
io << escape
reader.next_char
start_pos = reader.pos
end
io.write string.to_slice[start_pos, reader.pos - start_pos]
io << '"'
end
end
# Writes a raw value, considered a scalar, directly into
# the IO without processing. This is the only method that
# might lead to invalid JSON being generated, so you must
# be sure that *string* contains a valid JSON string.
def raw(string : String)
scalar do
@io << string
end
end
# Writes the start of an array.
def start_array
start_scalar
increase_indent
@state.push ArrayState.new(empty: true)
@io << '['
end
# Writes the end of an array.
def end_array
case state = @state.last
when ArrayState
@state.pop
else
raise JSON::Error.new("Can't do end_array: not inside an array")
end
write_indent state
@io << ']'
decrease_indent
end_scalar
end
# Writes the start of an array, invokes the block,
# and the writes the end of it.
def array
start_array
yield.tap { end_array }
end
# Writes the start of an object.
def start_object
start_scalar
increase_indent
@state.push ObjectState.new(empty: true, name: true)
@io << '{'
end
# Writes the end of an object.
def end_object
case state = @state.last
when ObjectState
unless state.name
raise JSON::Error.new("Missing object value")
end
@state.pop
else
raise JSON::Error.new("Can't do end_object: not inside an object")
end
write_indent state
@io << '}'
decrease_indent
end_scalar
end
# Writes the start of an object, invokes the block,
# and the writes the end of it.
def object
start_object
yield.tap { end_object }
end
# Writes a scalar value.
def scalar(value : Nil)
null
end
# :ditto:
def scalar(value : Bool)
bool(value)
end
# :ditto:
def scalar(value : Int | Float)
number(value)
end
# :ditto:
def scalar(value : String)
string(value)
end
# Writes an object's field and value.
# The field's name is first converted to a `String` by invoking
# `to_s` on it.
def field(name, value)
string(name)
value.to_json(self)
end
# Writes an object's field and then invokes the block.
# This is equivalent of invoking `string(value)` and then
# invoking the block.
def field(name)
string(name)
yield
end
# Flushes the underlying `IO`.
def flush
@io.flush
end
# Sets the indent *string*.
def indent=(string : String)
if string.empty?
@indent = nil
else
@indent = string
end
end
# Sets the indent *level* (number of spaces).
def indent=(level : Int)
if level < 0
@indent = nil
else
@indent = " " * level
end
end
# Returns `true` if the next thing that must pushed into this
# builder is an object key (so a string) or the end of an object.
def next_is_object_key? : Bool
state = @state.last
state.is_a?(ObjectState) && state.name
end
private def scalar(string = false)
start_scalar(string)
yield.tap { end_scalar(string) }
end
private def start_scalar(string = false)
object_value = false
case state = @state.last
when DocumentStartState
# okay
when StartState
raise JSON::Error.new("Write before start_document")
when DocumentEndState
raise JSON::Error.new("Write past end_document and before start_document")
when ArrayState
comma unless state.empty
when ObjectState
if state.name && !string
raise JSON::Error.new("Expected string for object name")
end
comma if state.name && !state.empty
object_value = !state.name
end
write_indent unless object_value
end
private def end_scalar(string = false)
case state = @state.last
when DocumentStartState
@state[-1] = DocumentEndState.new
when ArrayState
@state[-1] = ArrayState.new(empty: false)
when ObjectState
colon if state.name
@state[-1] = ObjectState.new(empty: false, name: !state.name)
else
raise "Bug: unexpected state: #{state.class}"
end
end
private def comma
@io << ','
end
private def colon
@io << ':'
@io << ' ' if @indent
end
private def newline
@io << '\n'
end
private def write_indent
indent = @indent
return unless indent
return if @current_indent == 0
write_indent(indent, @current_indent)
end
private def write_indent(state : State)
return if state.empty
indent = @indent
return unless indent
write_indent(indent, @current_indent - 1)
end
private def write_indent(indent, times)
newline
times.times do
@io << indent
end
end
private def increase_indent
@current_indent += 1
if @current_indent > @max_nesting
raise JSON::Error.new("Nesting of #{@current_indent} is too deep")
end
end
private def decrease_indent
@current_indent -= 1
end
end
module JSON
# Returns the resulting `String` of writing JSON to the yielded `JSON::Builder`.
#
# ```
# require "json"
#
# string = JSON.build do |json|
# json.object do
# json.field "name", "foo"
# json.field "values" do
# json.array do
# json.number 1
# json.number 2
# json.number 3
# end
# end
# end
# end
# string # => %<{"name":"foo","values":[1,2,3]}>
# ```
def self.build(indent = nil)
String.build do |str|
build(str, indent) do |json|
yield json
end
end
end
# Writes JSON into the given `IO`. A `JSON::Builder` is yielded to the block.
def self.build(io : IO, indent = nil) : Nil
builder = JSON::Builder.new(io)
builder.indent = indent if indent
builder.document do
yield builder
end
io.flush
end
end