tep 0.11.3 → 0.11.5

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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +42 -2
  3. data/README.md +4 -4
  4. data/SINATRA_COMPAT.md +20 -20
  5. data/bin/tep +47 -10
  6. data/examples/api_gateway/app.rb +1 -1
  7. data/examples/blog/app.rb +17 -17
  8. data/examples/chat/app.rb +12 -12
  9. data/examples/chatbot/README.md +2 -2
  10. data/examples/chatbot/app.rb +24 -24
  11. data/examples/llm_gateway/app.rb +4 -4
  12. data/examples/pg_hello.rb +11 -1
  13. data/lib/spinel_kit/hex.rb +65 -0
  14. data/lib/spinel_kit/json.rb +151 -0
  15. data/lib/spinel_kit/json_decoder.rb +396 -0
  16. data/lib/{tep/logger.rb → spinel_kit/log.rb} +25 -21
  17. data/lib/spinel_kit/url.rb +166 -0
  18. data/lib/tep/auth_bearer_token.rb +6 -6
  19. data/lib/tep/auth_oauth2.rb +4 -4
  20. data/lib/tep/broadcast.rb +18 -80
  21. data/lib/tep/events.rb +37 -37
  22. data/lib/tep/http.rb +3 -3
  23. data/lib/tep/job.rb +2 -2
  24. data/lib/tep/jwt.rb +4 -4
  25. data/lib/tep/live_view.rb +4 -4
  26. data/lib/tep/llm.rb +13 -45
  27. data/lib/tep/mcp.rb +12 -12
  28. data/lib/tep/multipart.rb +1 -1
  29. data/lib/tep/net.rb +8 -3
  30. data/lib/tep/openai_server.rb +102 -94
  31. data/lib/tep/parser.rb +2 -2
  32. data/lib/tep/pg.rb +468 -14
  33. data/lib/tep/presence.rb +33 -329
  34. data/lib/tep/proxy.rb +7 -7
  35. data/lib/tep/request.rb +1 -1
  36. data/lib/tep/response.rb +1 -1
  37. data/lib/tep/router.rb +1 -1
  38. data/lib/tep/session.rb +2 -2
  39. data/lib/tep/version.rb +1 -1
  40. data/lib/tep.rb +57 -137
  41. data/spinel-ext.json +6 -0
  42. data/test/helper.rb +95 -8
  43. data/test/run_parallel.rb +44 -7
  44. data/test/test_auth.rb +17 -17
  45. data/test/test_auth_oauth2.rb +5 -5
  46. data/test/test_broadcast_pg.rb +1 -0
  47. data/test/test_http_pool.rb +4 -4
  48. data/test/test_http_pool_send.rb +3 -3
  49. data/test/test_json.rb +12 -12
  50. data/test/test_jwt.rb +4 -4
  51. data/test/test_live_view.rb +3 -3
  52. data/test/test_llm.rb +12 -9
  53. data/test/test_llm_gateway.rb +2 -2
  54. data/test/test_logger.rb +2 -2
  55. data/test/test_openai_server.rb +10 -1
  56. data/test/test_password.rb +3 -3
  57. data/test/test_pg.rb +1 -0
  58. data/test/test_presence_pg.rb +1 -0
  59. data/test/test_real_world.rb +6 -1
  60. data/test/test_shutdown.rb +40 -0
  61. metadata +23 -8
  62. data/lib/tep/json.rb +0 -572
  63. data/lib/tep/url.rb +0 -161
@@ -53,7 +53,7 @@ gw.before do |req, res, ureq|
53
53
  end
54
54
 
55
55
  # Stream when the client asked for it. OpenAI signals streaming with
56
- # `"stream": true` in the JSON body; Tep::Json has no bool getter, so
56
+ # `"stream": true` in the JSON body; SpinelKit::Json has no bool getter, so
57
57
  # we match the literal (with or without the space).
58
58
  gw.stream_request? do |req|
59
59
  b = req.raw_body
@@ -70,15 +70,15 @@ end
70
70
 
71
71
  # One inference event at end-of-stream -- the right cardinality.
72
72
  gw.on_stream_end do |req, out, stats|
73
- model = Tep::Json.get_str(req.raw_body, "model")
73
+ model = SpinelKit::Json.get_str(req.raw_body, "model")
74
74
  t0 = req.ivars["t0"].to_i
75
75
  wall = Time.now.to_i - t0
76
76
  if wall < 0
77
77
  wall = 0
78
78
  end
79
79
  extra = "{" +
80
- Tep::Json.encode_pair_str("request_id", req.req_headers["x-request-id"]) + "," +
81
- Tep::Json.encode_pair_str("principal_id", req.identity.principal_id) +
80
+ SpinelKit::Json.encode_pair_str("request_id", req.req_headers["x-request-id"]) + "," +
81
+ SpinelKit::Json.encode_pair_str("principal_id", req.identity.principal_id) +
82
82
  "}"
83
83
  # prompt_tokens unknown at the proxy (no tokenizer); completion_tokens
84
84
  # approximated by the SSE event count. wall_us is second-resolution
data/examples/pg_hello.rb CHANGED
@@ -16,6 +16,7 @@
16
16
  # today (matz/spinel#627). Once that lands, this example collapses
17
17
  # to the AR-shape `rescue PG::Error => e`.
18
18
  require_relative "../lib/tep"
19
+ require_relative "../lib/tep/pg" # opt-in PG backend (#216)
19
20
 
20
21
  PG_URL = ENV["PG_URL"] != nil && ENV["PG_URL"].length > 0 ? ENV["PG_URL"] : "postgresql:///postgres"
21
22
 
@@ -67,7 +68,16 @@ get '/error' do
67
68
  out = "rescued PG::UndefinedTable\n" +
68
69
  "sqlstate: " + c.last_sqlstate + "\n" +
69
70
  "is undefined-table? " + (c.last_sqlstate == "42P01" ? "yes" : "no") + "\n" +
70
- "is PG::Error? " + (e.is_a?(PG::Error) ? "yes" : "no") + "\n" +
71
+ # WORKAROUND -- REMOVE WHEN UPSTREAM LANDS: `e.is_a?(PG::Error)`
72
+ # miscompiles at spinel master (whole-program is_a? on a deep
73
+ # exception subclass vs an ancestor -- e here is rescued as
74
+ # PG::UndefinedTable, a PG::Error subclass, so this is always
75
+ # "yes"). Minimal `rescue Sub => e; e.is_a?(Super)` repros
76
+ # compile fine; it only trips in the full program. Hardcoded
77
+ # to "yes" to keep the example building for the re-pin. See
78
+ # matz/spinel#1434, tep#196. Original:
79
+ # (e.is_a?(PG::Error) ? "yes" : "no")
80
+ "is PG::Error? " + "yes" + "\n" +
71
81
  "message: " + e.message
72
82
  end
73
83
  c.close
@@ -0,0 +1,65 @@
1
+ # VENDORED from OriPekelman/spinelkit @ 09e8558 -- DO NOT EDIT HERE.
2
+ # Edit upstream and re-sync with `make vendor-spinelkit`.
3
+ # SpinelKit::Hex -- hex digit/byte encode + decode: the pieces every Spinel
4
+ # project re-rolls. The decode nibble appeared BYTE-IDENTICAL in Tep::Url,
5
+ # inside SpinelKit::Json's string decoder, and (as a multi-digit variant) in
6
+ # Tep::Llm's chunked-transfer size parser. Pure string/byte ops, Spinel-safe.
7
+ #
8
+ # NOTE on the Json overlap: SpinelKit::Json keeps its OWN private `hex2`/
9
+ # `hex_nibble` so a JSON-only consumer never compiles this file (Spinel has no
10
+ # tree-shaking — see json.rb). Hex is the shared surface for everyone else,
11
+ # e.g. SpinelKit::Url.
12
+ module SpinelKit
13
+ class Hex
14
+ # Hex digit char -> int 0..15, or -1 if not a hex digit (upper or lower).
15
+ def self.nibble(c)
16
+ if c >= "0" && c <= "9"
17
+ return c.getbyte(0) - "0".getbyte(0)
18
+ end
19
+ if c >= "a" && c <= "f"
20
+ return c.getbyte(0) - "a".getbyte(0) + 10
21
+ end
22
+ if c >= "A" && c <= "F"
23
+ return c.getbyte(0) - "A".getbyte(0) + 10
24
+ end
25
+ -1
26
+ end
27
+
28
+ # Int nibble 0..15 -> single UPPERCASE hex char ("0".."9","A".."F").
29
+ # (RFC 3986 percent-encoding uses uppercase.)
30
+ def self.nibble_char(n)
31
+ if n < 10
32
+ return ("0".getbyte(0) + n).chr
33
+ end
34
+ ("A".getbyte(0) + n - 10).chr
35
+ end
36
+
37
+ # Int byte 0..255 -> two-char LOWERCASE hex (15 -> "0f"). (JSON \u00XX
38
+ # and similar use lowercase.)
39
+ def self.byte2(n)
40
+ hex = "0123456789abcdef"
41
+ out = ""
42
+ out = out + hex[(n / 16) % 16, 1]
43
+ out = out + hex[n % 16, 1]
44
+ out
45
+ end
46
+
47
+ # Parse the leading hex digits of `s` -> int ("1a3" -> 419). Stops at the
48
+ # first non-hex char; returns 0 if there is no leading hex digit. Useful
49
+ # for chunked-transfer sizes and the like.
50
+ def self.to_int(s)
51
+ n = 0
52
+ i = 0
53
+ len = s.length
54
+ while i < len
55
+ v = Hex.nibble(s[i])
56
+ if v < 0
57
+ return n
58
+ end
59
+ n = n * 16 + v
60
+ i += 1
61
+ end
62
+ n
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,151 @@
1
+ # VENDORED from OriPekelman/spinelkit @ 09e8558 -- DO NOT EDIT HERE.
2
+ # Edit upstream and re-sync with `make vendor-spinelkit`.
3
+ # SpinelKit::Json -- Spinel-safe JSON ENCODERS (stateless).
4
+ #
5
+ # This file holds the encode half of the codec; the decode half lives in
6
+ # spinel_kit/json_decoder.rb (also `SpinelKit::Json`), and the incremental
7
+ # object builder in spinel_kit/json_builder.rb (`SpinelKit::Json::Builder`).
8
+ # The three are split because Spinel has no tree-shaking: every loaded method
9
+ # is compiled, and a set of uncalled methods can degrade each other's params
10
+ # (e.g. the dead decoder walkers collectively widening `escape`'s string arg
11
+ # to int, which silently miscompiled string keys to ""). Keeping
12
+ # encode/decode/build in separate files means a consumer compiles only the
13
+ # surface it calls, and each surface is independently warning-clean. Require
14
+ # only what you use:
15
+ #
16
+ # require "spinel_kit/json" # encoders (this file)
17
+ # require "spinel_kit/json_decoder" # decoders
18
+ # require "spinel_kit/json_builder" # builder
19
+ #
20
+ # WHY HAND-ROLLED. Spinel cannot lower the stdlib `json` gem (C-ext fast path
21
+ # + metaprogrammed pure fallback); `oj`/`yajl`/`multi_json` are C extensions.
22
+ # The spinelgems catalog confirms no verified pure-Ruby JSON gem exists. This
23
+ # is tep's encoder, standardized (the `j_`/`tj_` prefixes that worked around a
24
+ # now-fixed Spinel inference bug are gone -- see docs/spinel-discipline.md).
25
+ #
26
+ # Compose objects in user code by concatenation:
27
+ #
28
+ # "{" + SpinelKit::Json.encode_pair_str("name", name) + "," +
29
+ # SpinelKit::Json.encode_pair_int("age", age) + "}"
30
+ module SpinelKit
31
+ class Json
32
+ # Escape a string for inclusion inside a JSON string literal (does NOT
33
+ # add the surrounding quotes -- use `quote(s)` for that). Handles ", \,
34
+ # and the JSON-required control-char escapes (\b, \f, \n, \r, \t);
35
+ # other control bytes go through \u00XX. Forward slash is left
36
+ # unescaped (legal either way; unescaped is shorter/readable).
37
+ def self.escape(s)
38
+ out = ""
39
+ i = 0
40
+ n = s.length
41
+ while i < n
42
+ c = s[i]
43
+ if c == "\""
44
+ out = out + "\\\""
45
+ elsif c == "\\"
46
+ out = out + "\\\\"
47
+ elsif c == "\n"
48
+ out = out + "\\n"
49
+ elsif c == "\r"
50
+ out = out + "\\r"
51
+ elsif c == "\t"
52
+ out = out + "\\t"
53
+ elsif c == "\b"
54
+ out = out + "\\b"
55
+ elsif c == "\f"
56
+ out = out + "\\f"
57
+ elsif c < " "
58
+ # Other control byte -- emit \u00XX. c.getbyte(0) is the raw
59
+ # byte value, mapped to two hex digits.
60
+ b = c.getbyte(0)
61
+ out = out + "\\u00" + Json.hex2(b)
62
+ else
63
+ out = out + c
64
+ end
65
+ i += 1
66
+ end
67
+ out
68
+ end
69
+
70
+ # Two-digit lowercase hex of a byte (0..255).
71
+ def self.hex2(n)
72
+ hex = "0123456789abcdef"
73
+ out = ""
74
+ out = out + hex[(n / 16) % 16, 1]
75
+ out = out + hex[n % 16, 1]
76
+ out
77
+ end
78
+
79
+ # Wrap a string in JSON quotes, escaping its body.
80
+ def self.quote(s)
81
+ "\"" + Json.escape(s) + "\""
82
+ end
83
+
84
+ # Encode a single key/value pair as `"k":"v"` (escaped both sides).
85
+ def self.encode_pair_str(k, v)
86
+ Json.quote(k) + ":" + Json.quote(v)
87
+ end
88
+
89
+ # Same shape, integer value side. `v` is rendered via `.to_s` so
90
+ # JSON-numeric output without quoting.
91
+ def self.encode_pair_int(k, v)
92
+ Json.quote(k) + ":" + v.to_s
93
+ end
94
+
95
+ # Encode a Hash<String,String> as a JSON object.
96
+ def self.from_str_hash(h)
97
+ out = "{"
98
+ first = true
99
+ h.each do |k, v|
100
+ if !first
101
+ out = out + ","
102
+ end
103
+ first = false
104
+ out = out + Json.quote(k) + ":" + Json.quote(v)
105
+ end
106
+ out + "}"
107
+ end
108
+
109
+ # Same shape with integer values. JSON-numeric, no quoting.
110
+ def self.from_int_hash(h)
111
+ out = "{"
112
+ first = true
113
+ h.each do |k, v|
114
+ if !first
115
+ out = out + ","
116
+ end
117
+ first = false
118
+ out = out + Json.quote(k) + ":" + v.to_s
119
+ end
120
+ out + "}"
121
+ end
122
+
123
+ # Encode a string array as a JSON array of quoted strings.
124
+ def self.from_str_array(a)
125
+ out = "["
126
+ i = 0
127
+ while i < a.length
128
+ if i > 0
129
+ out = out + ","
130
+ end
131
+ out = out + Json.quote(a[i])
132
+ i += 1
133
+ end
134
+ out + "]"
135
+ end
136
+
137
+ # Encode an int array as a JSON array of numbers.
138
+ def self.from_int_array(a)
139
+ out = "["
140
+ i = 0
141
+ while i < a.length
142
+ if i > 0
143
+ out = out + ","
144
+ end
145
+ out = out + a[i].to_s
146
+ i += 1
147
+ end
148
+ out + "]"
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,396 @@
1
+ # VENDORED from OriPekelman/spinelkit @ 09e8558 -- DO NOT EDIT HERE.
2
+ # Edit upstream and re-sync with `make vendor-spinelkit`.
3
+ # SpinelKit::Json -- Spinel-safe JSON DECODERS (flat-key, top-level only).
4
+ #
5
+ # The decode half of the codec; encoders are in spinel_kit/json.rb. Split out
6
+ # so an encode-only consumer never compiles these walkers (their dead-code
7
+ # degradation otherwise widens the encoders' string args to int -- see the
8
+ # header of json.rb and docs/spinel-discipline.md).
9
+ #
10
+ # `get_str(s, key)` finds the entry for `key` in the top-level object literal
11
+ # `s` and returns its value as a string. Returns "" when `key` is absent or
12
+ # the value isn't a string. Same shape for `get_int`. `has_key?(s, key)`
13
+ # returns a boolean independent of value type. The parser is a hand-rolled
14
+ # state machine that walks one `{ "k": <value>, ... }` pair at a time,
15
+ # skipping over any value (including nested objects / arrays) it doesn't need.
16
+ # Strings inside values are honoured for escape sequences so that `\"` doesn't
17
+ # terminate the string and corrupt the walk. Decodes the escape sequences
18
+ # `SpinelKit::Json.escape` produces.
19
+ module SpinelKit
20
+ class Json
21
+ def self.get_str(s, key)
22
+ pos = Json.find_value_start(s, key)
23
+ if pos < 0
24
+ return ""
25
+ end
26
+ Json.parse_str_value(s, pos)
27
+ end
28
+
29
+ def self.get_int(s, key)
30
+ pos = Json.find_value_start(s, key)
31
+ if pos < 0
32
+ return 0
33
+ end
34
+ Json.parse_int_value(s, pos)
35
+ end
36
+
37
+ # Decode a JSON number value at `key` -> Float. Accepts both
38
+ # integer-literal (`42`) and float-literal (`3.14`, `-0.5`, `1e2`)
39
+ # JSON-number syntax; the integer form returns N.0. Missing key or
40
+ # malformed value returns 0.0 (consistent with the other getters'
41
+ # missing-key defaults).
42
+ #
43
+ # Implementation: delegates the value-span walking to skip_value (already
44
+ # handles all JSON-number syntax + structural-char boundaries), then
45
+ # String#to_f on the substring. Inlined rather than factored into a
46
+ # parse_float_value helper because spinel's type inference mis-widens `s`
47
+ # to int through the indirection. NOTE: that is a value-walk indirection
48
+ # concern, NOT the name-collision bug (which was fixed) -- keep it inlined.
49
+ def self.get_float(s, key)
50
+ pos = Json.find_value_start(s, key)
51
+ if pos < 0
52
+ return 0.0
53
+ end
54
+ pos = Json.skip_ws(s, pos)
55
+ if pos >= s.length
56
+ return 0.0
57
+ end
58
+ end_pos = Json.skip_value(s, pos)
59
+ if end_pos <= pos
60
+ return 0.0
61
+ end
62
+ s[pos, end_pos - pos].to_f
63
+ end
64
+
65
+ def self.has_key?(s, key)
66
+ Json.find_value_start(s, key) >= 0
67
+ end
68
+
69
+ # Decode a flat JSON array of integers at `key` -> Array[Integer].
70
+ # A missing or non-array value yields [] (the typed-empty-array idiom);
71
+ # non-int elements are skipped.
72
+ def self.get_int_array(s, key)
73
+ out = [0]
74
+ out.delete_at(0)
75
+ pos = Json.find_value_start(s, key)
76
+ if pos < 0
77
+ return out
78
+ end
79
+ pos = Json.skip_ws(s, pos)
80
+ if pos >= s.length || s[pos] != "["
81
+ return out
82
+ end
83
+ pos += 1
84
+ while pos < s.length
85
+ pos = Json.skip_ws(s, pos)
86
+ if pos >= s.length
87
+ return out
88
+ end
89
+ c = s[pos]
90
+ if c == "]"
91
+ return out
92
+ elsif c == ","
93
+ pos += 1
94
+ elsif (c >= "0" && c <= "9") || c == "-"
95
+ out.push(Json.parse_int_value(s, pos))
96
+ # Advance past the number parse_int_value just consumed
97
+ # (optional '-' then digits).
98
+ if s[pos] == "-"
99
+ pos += 1
100
+ end
101
+ while pos < s.length && s[pos] >= "0" && s[pos] <= "9"
102
+ pos += 1
103
+ end
104
+ else
105
+ # Non-int element (string / object / etc.): skip it.
106
+ pos = Json.skip_value(s, pos)
107
+ end
108
+ end
109
+ out
110
+ end
111
+
112
+ # ---- Internal helpers ----
113
+
114
+ # Skip whitespace starting at `pos`, return the new position.
115
+ def self.skip_ws(s, pos)
116
+ while pos < s.length
117
+ c = s[pos]
118
+ if c == " " || c == "\t" || c == "\n" || c == "\r"
119
+ pos += 1
120
+ else
121
+ return pos
122
+ end
123
+ end
124
+ pos
125
+ end
126
+
127
+ # Walk a JSON-quoted string starting at `pos` (which must point at the
128
+ # opening `"`). Returns the position one past the closing `"`. Returns
129
+ # -1 on malformed input.
130
+ def self.skip_str(s, pos)
131
+ if pos >= s.length || s[pos] != "\""
132
+ return -1
133
+ end
134
+ pos += 1
135
+ while pos < s.length
136
+ c = s[pos]
137
+ if c == "\\"
138
+ # Skip the escape and the escaped character. \uXXXX spans 6
139
+ # chars total but skipping 2 still keeps us inside the string
140
+ # for the rest of the walk -- the remaining 4 hex digits look
141
+ # like ordinary string bytes and won't terminate the literal.
142
+ pos += 2
143
+ elsif c == "\""
144
+ return pos + 1
145
+ else
146
+ pos += 1
147
+ end
148
+ end
149
+ -1
150
+ end
151
+
152
+ # Walk a JSON value starting at `pos` (which must point at the first
153
+ # non-ws char of the value). Returns the position one past the value
154
+ # (or the input length on truncation).
155
+ def self.skip_value(s, pos)
156
+ pos = Json.skip_ws(s, pos)
157
+ if pos >= s.length
158
+ return pos
159
+ end
160
+ c = s[pos]
161
+ if c == "\""
162
+ return Json.skip_str(s, pos)
163
+ end
164
+ if c == "{" || c == "["
165
+ return Json.skip_container(s, pos)
166
+ end
167
+ # number / true / false / null -- read until the next structural /
168
+ # whitespace char.
169
+ while pos < s.length
170
+ c = s[pos]
171
+ if c == "," || c == "}" || c == "]" ||
172
+ c == " " || c == "\t" || c == "\n" || c == "\r"
173
+ return pos
174
+ end
175
+ pos += 1
176
+ end
177
+ pos
178
+ end
179
+
180
+ # Walk a balanced { ... } or [ ... ] starting at `pos`. Honours string
181
+ # literals so that `{` / `}` inside a value-string don't confuse the
182
+ # brace counter. Returns position one past the matching closer.
183
+ def self.skip_container(s, pos)
184
+ open_c = s[pos]
185
+ close_c = open_c == "{" ? "}" : "]"
186
+ depth = 1
187
+ pos += 1
188
+ while pos < s.length && depth > 0
189
+ c = s[pos]
190
+ if c == "\""
191
+ # whole nested string -- skip past it
192
+ npos = Json.skip_str(s, pos)
193
+ if npos < 0
194
+ return s.length
195
+ end
196
+ pos = npos
197
+ elsif c == open_c
198
+ depth += 1
199
+ pos += 1
200
+ elsif c == close_c
201
+ depth -= 1
202
+ pos += 1
203
+ else
204
+ pos += 1
205
+ end
206
+ end
207
+ pos
208
+ end
209
+
210
+ # Read a JSON-quoted string at `pos` and return its decoded contents
211
+ # (no surrounding quotes). Decodes the same escape sequences that
212
+ # `escape` produces. Returns "" on malformed input.
213
+ def self.parse_str_value(s, pos)
214
+ pos = Json.skip_ws(s, pos)
215
+ if pos >= s.length || s[pos] != "\""
216
+ return ""
217
+ end
218
+ pos += 1
219
+ out = ""
220
+ while pos < s.length
221
+ c = s[pos]
222
+ if c == "\""
223
+ return out
224
+ end
225
+ if c == "\\"
226
+ if pos + 1 >= s.length
227
+ return out
228
+ end
229
+ esc = s[pos + 1]
230
+ if esc == "\""
231
+ out = out + "\""
232
+ elsif esc == "\\"
233
+ out = out + "\\"
234
+ elsif esc == "/"
235
+ out = out + "/"
236
+ elsif esc == "n"
237
+ out = out + "\n"
238
+ elsif esc == "r"
239
+ out = out + "\r"
240
+ elsif esc == "t"
241
+ out = out + "\t"
242
+ elsif esc == "b"
243
+ out = out + "\b"
244
+ elsif esc == "f"
245
+ out = out + "\f"
246
+ elsif esc == "u"
247
+ # \u00XX -> map the two-digit hex back to a byte. Wider
248
+ # codepoints (U+0100+ or surrogate pairs) aren't decoded; the
249
+ # byte we emit is the low byte of the codepoint, which
250
+ # round-trips ASCII at minimum.
251
+ if pos + 5 < s.length
252
+ h1 = Json.hex_nibble(s[pos + 4])
253
+ h2 = Json.hex_nibble(s[pos + 5])
254
+ if h1 >= 0 && h2 >= 0
255
+ # rebuild the byte and push it -- spinel strings are
256
+ # byte-blobs, so this works for ASCII; for non-ASCII the
257
+ # original encoder would have used a passthrough byte
258
+ # anyway.
259
+ b = h1 * 16 + h2
260
+ out = out + Json.byte_to_chr(b)
261
+ pos += 6
262
+ next
263
+ end
264
+ end
265
+ out = out + "?"
266
+ pos += 2
267
+ next
268
+ else
269
+ out = out + esc
270
+ end
271
+ pos += 2
272
+ else
273
+ out = out + c
274
+ pos += 1
275
+ end
276
+ end
277
+ out
278
+ end
279
+
280
+ def self.hex_nibble(c)
281
+ if c >= "0" && c <= "9"
282
+ return c.getbyte(0) - "0".getbyte(0)
283
+ end
284
+ if c >= "a" && c <= "f"
285
+ return c.getbyte(0) - "a".getbyte(0) + 10
286
+ end
287
+ if c >= "A" && c <= "F"
288
+ return c.getbyte(0) - "A".getbyte(0) + 10
289
+ end
290
+ -1
291
+ end
292
+
293
+ # Build a single-byte string from an integer 0..255. Spinel doesn't
294
+ # expose `n.chr` for arbitrary bytes uniformly; the table covers the
295
+ # ASCII printable range and falls back to "?" for anything else (the
296
+ # JSON encoder side never produces non-ASCII via \u, so the fallback
297
+ # is reachable only for malformed input).
298
+ def self.byte_to_chr(n)
299
+ printable = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
300
+ if n >= 32 && n < 127
301
+ return printable[n - 32, 1]
302
+ end
303
+ if n == 9
304
+ return "\t"
305
+ end
306
+ if n == 10
307
+ return "\n"
308
+ end
309
+ if n == 13
310
+ return "\r"
311
+ end
312
+ "?"
313
+ end
314
+
315
+ # Read an integer at `pos`. Accepts an optional leading `-`. Returns 0
316
+ # on no-digit / non-numeric input (caller can use `has_key?` first if
317
+ # 0-vs-absent matters).
318
+ def self.parse_int_value(s, pos)
319
+ pos = Json.skip_ws(s, pos)
320
+ if pos >= s.length
321
+ return 0
322
+ end
323
+ neg = false
324
+ if s[pos] == "-"
325
+ neg = true
326
+ pos += 1
327
+ end
328
+ n = 0
329
+ saw_digit = false
330
+ while pos < s.length
331
+ c = s[pos]
332
+ if c >= "0" && c <= "9"
333
+ n = n * 10 + (c.getbyte(0) - "0".getbyte(0))
334
+ saw_digit = true
335
+ pos += 1
336
+ else
337
+ break
338
+ end
339
+ end
340
+ if !saw_digit
341
+ return 0
342
+ end
343
+ neg ? -n : n
344
+ end
345
+
346
+ # Walk the top-level object looking for the entry whose key matches
347
+ # `target_key`; return the position of the value's first non-ws
348
+ # character. Returns -1 if not found.
349
+ def self.find_value_start(s, target_key)
350
+ pos = Json.skip_ws(s, 0)
351
+ if pos >= s.length || s[pos] != "{"
352
+ return -1
353
+ end
354
+ pos += 1
355
+ while pos < s.length
356
+ pos = Json.skip_ws(s, pos)
357
+ if pos >= s.length
358
+ return -1
359
+ end
360
+ if s[pos] == "}"
361
+ return -1
362
+ end
363
+ # Read a key.
364
+ if s[pos] != "\""
365
+ return -1
366
+ end
367
+ key_start = pos
368
+ pos = Json.skip_str(s, pos)
369
+ if pos < 0
370
+ return -1
371
+ end
372
+ # Decode the key for comparison (handles \" inside keys).
373
+ key = Json.parse_str_value(s, key_start)
374
+ # Skip ws, ":".
375
+ pos = Json.skip_ws(s, pos)
376
+ if pos >= s.length || s[pos] != ":"
377
+ return -1
378
+ end
379
+ pos += 1
380
+ pos = Json.skip_ws(s, pos)
381
+ if key == target_key
382
+ return pos
383
+ end
384
+ # Skip the value, then the comma (if any).
385
+ pos = Json.skip_value(s, pos)
386
+ pos = Json.skip_ws(s, pos)
387
+ if pos < s.length && s[pos] == ","
388
+ pos += 1
389
+ elsif pos < s.length && s[pos] == "}"
390
+ return -1
391
+ end
392
+ end
393
+ -1
394
+ end
395
+ end
396
+ end