Class: SpinelKit::Json

Inherits:
Object
  • Object
show all
Defined in:
lib/spinel_kit/json.rb,
lib/spinel_kit/json_builder.rb,
lib/spinel_kit/json_decoder.rb

Defined Under Namespace

Classes: Builder

Class Method Summary collapse

Class Method Details

.byte_to_chr(n) ⇒ Object

Build a single-byte string from an integer 0..255. Spinel doesn’t expose ‘n.chr` for arbitrary bytes uniformly; the table covers the ASCII printable range and falls back to “?” for anything else (the JSON encoder side never produces non-ASCII via u, so the fallback is reachable only for malformed input).



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/spinel_kit/json_decoder.rb', line 296

def self.byte_to_chr(n)
  printable = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
  if n >= 32 && n < 127
    return printable[n - 32, 1]
  end
  if n == 9
    return "\t"
  end
  if n == 10
    return "\n"
  end
  if n == 13
    return "\r"
  end
  "?"
end

.encode_pair_int(k, v) ⇒ Object

Same shape, integer value side. ‘v` is rendered via `.to_s` so JSON-numeric output without quoting.



89
90
91
# File 'lib/spinel_kit/json.rb', line 89

def self.encode_pair_int(k, v)
  Json.quote(k) + ":" + v.to_s
end

.encode_pair_str(k, v) ⇒ Object

Encode a single key/value pair as ‘“k”:“v”` (escaped both sides).



83
84
85
# File 'lib/spinel_kit/json.rb', line 83

def self.encode_pair_str(k, v)
  Json.quote(k) + ":" + Json.quote(v)
end

.escape(s) ⇒ Object

Escape a string for inclusion inside a JSON string literal (does NOT add the surrounding quotes – use ‘quote(s)` for that). Handles “, , and the JSON-required control-char escapes (b, f, n, r, t); other control bytes go through u00XX. Forward slash is left unescaped (legal either way; unescaped is shorter/readable).



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
# File 'lib/spinel_kit/json.rb', line 35

def self.escape(s)
  out = ""
  i = 0
  n = s.length
  while i < n
    c = s[i]
    if c == "\""
      out = out + "\\\""
    elsif c == "\\"
      out = out + "\\\\"
    elsif c == "\n"
      out = out + "\\n"
    elsif c == "\r"
      out = out + "\\r"
    elsif c == "\t"
      out = out + "\\t"
    elsif c == "\b"
      out = out + "\\b"
    elsif c == "\f"
      out = out + "\\f"
    elsif c < " "
      # Other control byte -- emit \u00XX. c.getbyte(0) is the raw
      # byte value, mapped to two hex digits.
      b = c.getbyte(0)
      out = out + "\\u00" + Json.hex2(b)
    else
      out = out + c
    end
    i += 1
  end
  out
end

.find_value_start(s, target_key) ⇒ Object

Walk the top-level object looking for the entry whose key matches ‘target_key`; return the position of the value’s first non-ws character. Returns -1 if not found.



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
# File 'lib/spinel_kit/json_decoder.rb', line 347

def self.find_value_start(s, target_key)
  pos = Json.skip_ws(s, 0)
  if pos >= s.length || s[pos] != "{"
    return -1
  end
  pos += 1
  while pos < s.length
    pos = Json.skip_ws(s, pos)
    if pos >= s.length
      return -1
    end
    if s[pos] == "}"
      return -1
    end
    # Read a key.
    if s[pos] != "\""
      return -1
    end
    key_start = pos
    pos = Json.skip_str(s, pos)
    if pos < 0
      return -1
    end
    # Decode the key for comparison (handles \" inside keys).
    key = Json.parse_str_value(s, key_start)
    # Skip ws, ":".
    pos = Json.skip_ws(s, pos)
    if pos >= s.length || s[pos] != ":"
      return -1
    end
    pos += 1
    pos = Json.skip_ws(s, pos)
    if key == target_key
      return pos
    end
    # Skip the value, then the comma (if any).
    pos = Json.skip_value(s, pos)
    pos = Json.skip_ws(s, pos)
    if pos < s.length && s[pos] == ","
      pos += 1
    elsif pos < s.length && s[pos] == "}"
      return -1
    end
  end
  -1
end

.from_int_array(a) ⇒ Object

Encode an int array as a JSON array of numbers.



136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/spinel_kit/json.rb', line 136

def self.from_int_array(a)
  out = "["
  i = 0
  while i < a.length
    if i > 0
      out = out + ","
    end
    out = out + a[i].to_s
    i += 1
  end
  out + "]"
end

.from_int_hash(h) ⇒ Object

Same shape with integer values. JSON-numeric, no quoting.



108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/spinel_kit/json.rb', line 108

def self.from_int_hash(h)
  out = "{"
  first = true
  h.each do |k, v|
    if !first
      out = out + ","
    end
    first = false
    out = out + Json.quote(k) + ":" + v.to_s
  end
  out + "}"
end

.from_str_array(a) ⇒ Object

Encode a string array as a JSON array of quoted strings.



122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/spinel_kit/json.rb', line 122

def self.from_str_array(a)
  out = "["
  i = 0
  while i < a.length
    if i > 0
      out = out + ","
    end
    out = out + Json.quote(a[i])
    i += 1
  end
  out + "]"
end

.from_str_hash(h) ⇒ Object

Encode a Hash<String,String> as a JSON object.



94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/spinel_kit/json.rb', line 94

def self.from_str_hash(h)
  out = "{"
  first = true
  h.each do |k, v|
    if !first
      out = out + ","
    end
    first = false
    out = out + Json.quote(k) + ":" + Json.quote(v)
  end
  out + "}"
end

.get_float(s, key) ⇒ Object

Decode a JSON number value at ‘key` -> Float. Accepts both integer-literal (`42`) and float-literal (`3.14`, `-0.5`, `1e2`) JSON-number syntax; the integer form returns N.0. Missing key or malformed value returns 0.0 (consistent with the other getters’ missing-key defaults).

Implementation: delegates the value-span walking to skip_value (already handles all JSON-number syntax + structural-char boundaries), then String#to_f on the substring. Inlined rather than factored into a parse_float_value helper because spinel’s type inference mis-widens ‘s` to int through the indirection. NOTE: that is a value-walk indirection concern, NOT the name-collision bug (which was fixed) – keep it inlined.



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/spinel_kit/json_decoder.rb', line 47

def self.get_float(s, key)
  pos = Json.find_value_start(s, key)
  if pos < 0
    return 0.0
  end
  pos = Json.skip_ws(s, pos)
  if pos >= s.length
    return 0.0
  end
  end_pos = Json.skip_value(s, pos)
  if end_pos <= pos
    return 0.0
  end
  s[pos, end_pos - pos].to_f
end

.get_int(s, key) ⇒ Object



27
28
29
30
31
32
33
# File 'lib/spinel_kit/json_decoder.rb', line 27

def self.get_int(s, key)
  pos = Json.find_value_start(s, key)
  if pos < 0
    return 0
  end
  Json.parse_int_value(s, pos)
end

.get_int_array(s, key) ⇒ Object

Decode a flat JSON array of integers at ‘key` -> Array. A missing or non-array value yields [] (the typed-empty-array idiom); non-int elements are skipped.



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
# File 'lib/spinel_kit/json_decoder.rb', line 70

def self.get_int_array(s, key)
  out = [0]
  out.delete_at(0)
  pos = Json.find_value_start(s, key)
  if pos < 0
    return out
  end
  pos = Json.skip_ws(s, pos)
  if pos >= s.length || s[pos] != "["
    return out
  end
  pos += 1
  while pos < s.length
    pos = Json.skip_ws(s, pos)
    if pos >= s.length
      return out
    end
    c = s[pos]
    if c == "]"
      return out
    elsif c == ","
      pos += 1
    elsif (c >= "0" && c <= "9") || c == "-"
      out.push(Json.parse_int_value(s, pos))
      # Advance past the number parse_int_value just consumed
      # (optional '-' then digits).
      if s[pos] == "-"
        pos += 1
      end
      while pos < s.length && s[pos] >= "0" && s[pos] <= "9"
        pos += 1
      end
    else
      # Non-int element (string / object / etc.): skip it.
      pos = Json.skip_value(s, pos)
    end
  end
  out
end

.get_str(s, key) ⇒ Object



19
20
21
22
23
24
25
# File 'lib/spinel_kit/json_decoder.rb', line 19

def self.get_str(s, key)
  pos = Json.find_value_start(s, key)
  if pos < 0
    return ""
  end
  Json.parse_str_value(s, pos)
end

.has_key?(s, key) ⇒ Boolean

Returns:

  • (Boolean)


63
64
65
# File 'lib/spinel_kit/json_decoder.rb', line 63

def self.has_key?(s, key)
  Json.find_value_start(s, key) >= 0
end

.hex2(n) ⇒ Object

Two-digit lowercase hex of a byte (0..255).



69
70
71
72
73
74
75
# File 'lib/spinel_kit/json.rb', line 69

def self.hex2(n)
  hex = "0123456789abcdef"
  out = ""
  out = out + hex[(n / 16) % 16, 1]
  out = out + hex[n % 16, 1]
  out
end

.hex_nibble(c) ⇒ Object



278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/spinel_kit/json_decoder.rb', line 278

def self.hex_nibble(c)
  if c >= "0" && c <= "9"
    return c.getbyte(0) - "0".getbyte(0)
  end
  if c >= "a" && c <= "f"
    return c.getbyte(0) - "a".getbyte(0) + 10
  end
  if c >= "A" && c <= "F"
    return c.getbyte(0) - "A".getbyte(0) + 10
  end
  -1
end

.parse_int_value(s, pos) ⇒ Object

Read an integer at ‘pos`. Accepts an optional leading `-`. Returns 0 on no-digit / non-numeric input (caller can use `has_key?` first if 0-vs-absent matters).



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
# File 'lib/spinel_kit/json_decoder.rb', line 316

def self.parse_int_value(s, pos)
  pos = Json.skip_ws(s, pos)
  if pos >= s.length
    return 0
  end
  neg = false
  if s[pos] == "-"
    neg = true
    pos += 1
  end
  n = 0
  saw_digit = false
  while pos < s.length
    c = s[pos]
    if c >= "0" && c <= "9"
      n = n * 10 + (c.getbyte(0) - "0".getbyte(0))
      saw_digit = true
      pos += 1
    else
      break
    end
  end
  if !saw_digit
    return 0
  end
  neg ? -n : n
end

.parse_str_value(s, pos) ⇒ Object

Read a JSON-quoted string at ‘pos` and return its decoded contents (no surrounding quotes). Decodes the same escape sequences that `escape` produces. Returns “” on malformed input.



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
# File 'lib/spinel_kit/json_decoder.rb', line 211

def self.parse_str_value(s, pos)
  pos = Json.skip_ws(s, pos)
  if pos >= s.length || s[pos] != "\""
    return ""
  end
  pos += 1
  out = ""
  while pos < s.length
    c = s[pos]
    if c == "\""
      return out
    end
    if c == "\\"
      if pos + 1 >= s.length
        return out
      end
      esc = s[pos + 1]
      if esc == "\""
        out = out + "\""
      elsif esc == "\\"
        out = out + "\\"
      elsif esc == "/"
        out = out + "/"
      elsif esc == "n"
        out = out + "\n"
      elsif esc == "r"
        out = out + "\r"
      elsif esc == "t"
        out = out + "\t"
      elsif esc == "b"
        out = out + "\b"
      elsif esc == "f"
        out = out + "\f"
      elsif esc == "u"
        # \u00XX -> map the two-digit hex back to a byte. Wider
        # codepoints (U+0100+ or surrogate pairs) aren't decoded; the
        # byte we emit is the low byte of the codepoint, which
        # round-trips ASCII at minimum.
        if pos + 5 < s.length
          h1 = Json.hex_nibble(s[pos + 4])
          h2 = Json.hex_nibble(s[pos + 5])
          if h1 >= 0 && h2 >= 0
            # rebuild the byte and push it -- spinel strings are
            # byte-blobs, so this works for ASCII; for non-ASCII the
            # original encoder would have used a passthrough byte
            # anyway.
            b = h1 * 16 + h2
            out = out + Json.byte_to_chr(b)
            pos += 6
            next
          end
        end
        out = out + "?"
        pos += 2
        next
      else
        out = out + esc
      end
      pos += 2
    else
      out = out + c
      pos += 1
    end
  end
  out
end

.quote(s) ⇒ Object

Wrap a string in JSON quotes, escaping its body.



78
79
80
# File 'lib/spinel_kit/json.rb', line 78

def self.quote(s)
  "\"" + Json.escape(s) + "\""
end

.skip_container(s, pos) ⇒ Object

Walk a balanced { … } or [ … ] starting at ‘pos`. Honours string literals so that `/ `` inside a value-string don’t confuse the brace counter. Returns position one past the matching closer.



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
# File 'lib/spinel_kit/json_decoder.rb', line 181

def self.skip_container(s, pos)
  open_c = s[pos]
  close_c = open_c == "{" ? "}" : "]"
  depth = 1
  pos += 1
  while pos < s.length && depth > 0
    c = s[pos]
    if c == "\""
      # whole nested string -- skip past it
      npos = Json.skip_str(s, pos)
      if npos < 0
        return s.length
      end
      pos = npos
    elsif c == open_c
      depth += 1
      pos += 1
    elsif c == close_c
      depth -= 1
      pos += 1
    else
      pos += 1
    end
  end
  pos
end

.skip_str(s, pos) ⇒ Object

Walk a JSON-quoted string starting at ‘pos` (which must point at the opening `“`). Returns the position one past the closing `”`. Returns -1 on malformed input.



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/spinel_kit/json_decoder.rb', line 128

def self.skip_str(s, pos)
  if pos >= s.length || s[pos] != "\""
    return -1
  end
  pos += 1
  while pos < s.length
    c = s[pos]
    if c == "\\"
      # Skip the escape and the escaped character. \uXXXX spans 6
      # chars total but skipping 2 still keeps us inside the string
      # for the rest of the walk -- the remaining 4 hex digits look
      # like ordinary string bytes and won't terminate the literal.
      pos += 2
    elsif c == "\""
      return pos + 1
    else
      pos += 1
    end
  end
  -1
end

.skip_value(s, pos) ⇒ Object

Walk a JSON value starting at ‘pos` (which must point at the first non-ws char of the value). Returns the position one past the value (or the input length on truncation).



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/spinel_kit/json_decoder.rb', line 153

def self.skip_value(s, pos)
  pos = Json.skip_ws(s, pos)
  if pos >= s.length
    return pos
  end
  c = s[pos]
  if c == "\""
    return Json.skip_str(s, pos)
  end
  if c == "{" || c == "["
    return Json.skip_container(s, pos)
  end
  # number / true / false / null -- read until the next structural /
  # whitespace char.
  while pos < s.length
    c = s[pos]
    if c == "," || c == "}" || c == "]" ||
       c == " " || c == "\t" || c == "\n" || c == "\r"
      return pos
    end
    pos += 1
  end
  pos
end

.skip_ws(s, pos) ⇒ Object

Skip whitespace starting at ‘pos`, return the new position.



113
114
115
116
117
118
119
120
121
122
123
# File 'lib/spinel_kit/json_decoder.rb', line 113

def self.skip_ws(s, pos)
  while pos < s.length
    c = s[pos]
    if c == " " || c == "\t" || c == "\n" || c == "\r"
      pos += 1
    else
      return pos
    end
  end
  pos
end