hbsig 0.3.2 → 0.3.3

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 (95) hide show
  1. package/.babelrc-cjs +5 -0
  2. package/.babelrc-esm +5 -0
  3. package/README.md +1 -0
  4. package/dist/package.json +39 -0
  5. package/make.js +36 -0
  6. package/package.json +16 -17
  7. package/src/bin_to_str.js +46 -0
  8. package/src/collect-body-keys.js +436 -0
  9. package/src/commit.js +219 -0
  10. package/src/encode-array-item.js +112 -0
  11. package/src/encode-utils.js +191 -0
  12. package/src/encode.js +1256 -0
  13. package/src/erl_json.js +292 -0
  14. package/src/erl_str.js +1144 -0
  15. package/src/flat.js +250 -0
  16. package/src/http-message-signatures/httpbis.js +438 -0
  17. package/src/http-message-signatures/index.js +4 -0
  18. package/src/http-message-signatures/structured-header.js +105 -0
  19. package/src/httpsig.js +866 -0
  20. package/src/id.js +459 -0
  21. package/src/index.js +13 -0
  22. package/src/nocrypto.js +4 -0
  23. package/src/parser.js +171 -0
  24. package/src/send-utils.js +1132 -0
  25. package/src/send.js +142 -0
  26. package/src/signer-utils.js +375 -0
  27. package/src/signer.js +312 -0
  28. package/src/structured.js +496 -0
  29. package/src/test.js +2 -0
  30. package/src/utils.js +29 -0
  31. package/test/commit.test.js +41 -0
  32. package/test/erl_json.test.js +8 -0
  33. package/test/flat.test.js +27 -0
  34. package/test/httpsig.test.js +31 -0
  35. package/test/id.test.js +114 -0
  36. package/test/lib/all_cases.js +408 -0
  37. package/test/lib/cases.js +408 -0
  38. package/test/lib/erl_json_cases.js +161 -0
  39. package/test/lib/flat_cases.js +189 -0
  40. package/test/lib/gen.js +528 -0
  41. package/test/lib/httpsig_cases.js +313 -0
  42. package/test/lib/structured_cases.js +222 -0
  43. package/test/lib/test-utils.js +399 -0
  44. package/test/signer.test.js +48 -0
  45. package/test/structured.test.js +35 -0
  46. package/bin/install-deps +0 -0
  47. /package/{cjs → dist/cjs}/bin_to_str.js +0 -0
  48. /package/{cjs → dist/cjs}/collect-body-keys.js +0 -0
  49. /package/{cjs → dist/cjs}/commit.js +0 -0
  50. /package/{cjs → dist/cjs}/encode-array-item.js +0 -0
  51. /package/{cjs → dist/cjs}/encode-utils.js +0 -0
  52. /package/{cjs → dist/cjs}/encode.js +0 -0
  53. /package/{cjs → dist/cjs}/erl_json.js +0 -0
  54. /package/{cjs → dist/cjs}/erl_str.js +0 -0
  55. /package/{cjs → dist/cjs}/flat.js +0 -0
  56. /package/{cjs → dist/cjs}/http-message-signatures/httpbis.js +0 -0
  57. /package/{cjs → dist/cjs}/http-message-signatures/index.js +0 -0
  58. /package/{cjs → dist/cjs}/http-message-signatures/structured-header.js +0 -0
  59. /package/{cjs → dist/cjs}/httpsig.js +0 -0
  60. /package/{cjs → dist/cjs}/id.js +0 -0
  61. /package/{cjs → dist/cjs}/index.js +0 -0
  62. /package/{cjs → dist/cjs}/nocrypto.js +0 -0
  63. /package/{cjs → dist/cjs}/parser.js +0 -0
  64. /package/{cjs → dist/cjs}/send-utils.js +0 -0
  65. /package/{cjs → dist/cjs}/send.js +0 -0
  66. /package/{cjs → dist/cjs}/signer-utils.js +0 -0
  67. /package/{cjs → dist/cjs}/signer.js +0 -0
  68. /package/{cjs → dist/cjs}/structured.js +0 -0
  69. /package/{cjs → dist/cjs}/test.js +0 -0
  70. /package/{cjs → dist/cjs}/utils.js +0 -0
  71. /package/{esm → dist/esm}/bin_to_str.js +0 -0
  72. /package/{esm → dist/esm}/collect-body-keys.js +0 -0
  73. /package/{esm → dist/esm}/commit.js +0 -0
  74. /package/{esm → dist/esm}/encode-array-item.js +0 -0
  75. /package/{esm → dist/esm}/encode-utils.js +0 -0
  76. /package/{esm → dist/esm}/encode.js +0 -0
  77. /package/{esm → dist/esm}/erl_json.js +0 -0
  78. /package/{esm → dist/esm}/erl_str.js +0 -0
  79. /package/{esm → dist/esm}/flat.js +0 -0
  80. /package/{esm → dist/esm}/http-message-signatures/httpbis.js +0 -0
  81. /package/{esm → dist/esm}/http-message-signatures/index.js +0 -0
  82. /package/{esm → dist/esm}/http-message-signatures/structured-header.js +0 -0
  83. /package/{esm → dist/esm}/httpsig.js +0 -0
  84. /package/{esm → dist/esm}/id.js +0 -0
  85. /package/{esm → dist/esm}/index.js +0 -0
  86. /package/{esm → dist/esm}/nocrypto.js +0 -0
  87. /package/{esm → dist/esm}/package.json +0 -0
  88. /package/{esm → dist/esm}/parser.js +0 -0
  89. /package/{esm → dist/esm}/send-utils.js +0 -0
  90. /package/{esm → dist/esm}/send.js +0 -0
  91. /package/{esm → dist/esm}/signer-utils.js +0 -0
  92. /package/{esm → dist/esm}/signer.js +0 -0
  93. /package/{esm → dist/esm}/structured.js +0 -0
  94. /package/{esm → dist/esm}/test.js +0 -0
  95. /package/{esm → dist/esm}/utils.js +0 -0
package/src/erl_str.js ADDED
@@ -0,0 +1,1144 @@
1
+ /**
2
+ * Erlang term string parser and formatter
3
+ * Converts between Erlang term strings and JavaScript values
4
+ */
5
+
6
+ /**
7
+ * Parse an Erlang term string into JavaScript values
8
+ * @param {string} str - Erlang term string
9
+ * @param {boolean} binaryMode - If true, keep binaries as Buffers; if false, convert to strings
10
+ * @returns {*} JavaScript value
11
+ */
12
+ export function erl_str_from(str, binaryMode = false) {
13
+ // Handle null/undefined input
14
+ if (str === null || str === undefined) {
15
+ return null
16
+ }
17
+
18
+ // Handle the new response format
19
+ if (str.startsWith("#erl_response{")) {
20
+ const rawMatch = str.match(/#erl_response\{raw=(.*?),formatted=(.*?)\}$/s)
21
+ if (rawMatch && rawMatch[1] && rawMatch[2]) {
22
+ const rawStr = rawMatch[1]
23
+ const formattedStr = rawMatch[2]
24
+
25
+ if (binaryMode) {
26
+ // In binary mode, just parse formatted
27
+ const parser = new ErlangParser(formattedStr, true)
28
+ return parser.parse()
29
+ } else {
30
+ // Build a type map from raw, then parse formatted with type info
31
+ const typeMap = buildTypeMap(rawStr)
32
+ const parser = new TypeAwareParser(formattedStr, typeMap)
33
+ return parser.parse()
34
+ }
35
+ }
36
+ }
37
+
38
+ // Fallback for non-response format
39
+ const parser = new ErlangParser(str, binaryMode)
40
+ return parser.parse()
41
+ }
42
+
43
+ /**
44
+ * Parse body field specially - convert to string only for multipart bodies
45
+ * @param {*} parsed - Parsed Erlang term object
46
+ * @returns {*} Object with body field potentially converted to string
47
+ */
48
+ export function parse_body(parsed) {
49
+ if (!parsed || typeof parsed !== "object") {
50
+ return parsed
51
+ }
52
+
53
+ // If there's a body field
54
+ if ("body" in parsed) {
55
+ if (Buffer.isBuffer(parsed.body)) {
56
+ // Check if this looks like a multipart body
57
+ // Multipart bodies start with "--" boundary
58
+ const bodyStart = parsed.body.toString(
59
+ "binary",
60
+ 0,
61
+ Math.min(100, parsed.body.length)
62
+ )
63
+ if (
64
+ bodyStart.startsWith("--") &&
65
+ parsed["content-type"] &&
66
+ parsed["content-type"].includes("multipart")
67
+ ) {
68
+ // It's a multipart body, convert to string
69
+ return {
70
+ ...parsed,
71
+ body: parsed.body.toString("binary"),
72
+ }
73
+ }
74
+ // Not multipart, keep as Buffer
75
+ return parsed
76
+ }
77
+ // Already a string, return as-is
78
+ return parsed
79
+ }
80
+
81
+ return parsed
82
+ }
83
+
84
+ // Build a map of paths to types by analyzing the raw string
85
+ function buildTypeMap(rawStr) {
86
+ const typeMap = new Map()
87
+
88
+ function detectBinaryType(str, startPos) {
89
+ // Check what follows <<
90
+ let pos = startPos + 2
91
+ while (pos < str.length && /\s/.test(str[pos])) pos++
92
+
93
+ if (pos < str.length && str[pos] === '"') {
94
+ // It looks like a string, but check if it has escape sequences
95
+ // If it has \NNN octal escapes, it's actually a binary that Erlang formatted as string
96
+ let scanPos = pos + 1
97
+ let hasOctalEscapes = false
98
+
99
+ while (
100
+ scanPos < str.length &&
101
+ !(
102
+ str[scanPos] === '"' &&
103
+ str[scanPos + 1] === ">" &&
104
+ str[scanPos + 2] === ">"
105
+ )
106
+ ) {
107
+ if (str[scanPos] === "\\" && scanPos + 1 < str.length) {
108
+ const nextChar = str[scanPos + 1]
109
+ // Check for octal escape \NNN
110
+ if (/[0-7]/.test(nextChar)) {
111
+ hasOctalEscapes = true
112
+ break
113
+ }
114
+ scanPos += 2
115
+ } else {
116
+ scanPos++
117
+ }
118
+ }
119
+
120
+ // If it has octal escapes, treat as binary
121
+ if (hasOctalEscapes) {
122
+ return "binary"
123
+ }
124
+
125
+ return "string"
126
+ }
127
+ return "binary"
128
+ }
129
+
130
+ function scanValue(str, pos, path) {
131
+ while (pos < str.length && /\s/.test(str[pos])) pos++
132
+
133
+ if (pos >= str.length) return pos
134
+
135
+ if (str[pos] === "#" && str[pos + 1] === "{") {
136
+ // Map
137
+ pos += 2
138
+ let mapIndex = 0
139
+
140
+ while (pos < str.length) {
141
+ while (pos < str.length && /\s/.test(str[pos])) pos++
142
+ if (str[pos] === "}") return pos + 1
143
+
144
+ // Parse key
145
+ const keyStart = pos
146
+ if (str[pos] === "<" && str[pos + 1] === "<") {
147
+ // Binary key
148
+ const keyType = detectBinaryType(str, pos)
149
+ let keyEnd = pos + 2
150
+
151
+ if (keyType === "string") {
152
+ keyEnd++ // skip "
153
+ while (
154
+ keyEnd < str.length &&
155
+ !(
156
+ str[keyEnd] === '"' &&
157
+ str[keyEnd + 1] === ">" &&
158
+ str[keyEnd + 2] === ">"
159
+ )
160
+ ) {
161
+ if (str[keyEnd] === "\\") keyEnd++
162
+ keyEnd++
163
+ }
164
+ keyEnd += 3
165
+ } else {
166
+ let depth = 1
167
+ while (depth > 0 && keyEnd < str.length) {
168
+ if (str[keyEnd] === "<" && str[keyEnd + 1] === "<") {
169
+ depth++
170
+ keyEnd += 2
171
+ } else if (str[keyEnd] === ">" && str[keyEnd + 1] === ">") {
172
+ depth--
173
+ keyEnd += 2
174
+ } else {
175
+ keyEnd++
176
+ }
177
+ }
178
+ }
179
+
180
+ pos = keyEnd
181
+ } else {
182
+ // Regular key
183
+ while (pos < str.length && !/[\s=,}]/.test(str[pos])) pos++
184
+ }
185
+
186
+ // Skip =>
187
+ while (pos < str.length && /\s/.test(str[pos])) pos++
188
+ if (str[pos] === "=" && str[pos + 1] === ">") pos += 2
189
+ while (pos < str.length && /\s/.test(str[pos])) pos++
190
+
191
+ // Parse value and record type if binary
192
+ if (str[pos] === "<" && str[pos + 1] === "<") {
193
+ const type = detectBinaryType(str, pos)
194
+ const pathKey = [...path, mapIndex].join(".")
195
+ typeMap.set(pathKey, type)
196
+ }
197
+
198
+ pos = scanValue(str, pos, [...path, mapIndex])
199
+ mapIndex++
200
+
201
+ // Skip comma
202
+ while (pos < str.length && /\s/.test(str[pos])) pos++
203
+ if (str[pos] === ",") pos++
204
+ }
205
+ } else if (str[pos] === "<" && str[pos + 1] === "<") {
206
+ // Binary
207
+ const type = detectBinaryType(str, pos)
208
+ const pathKey = path.join(".")
209
+ typeMap.set(pathKey, type)
210
+
211
+ pos += 2
212
+ if (type === "string" || str[pos] === '"') {
213
+ // Skip past string literal
214
+ if (str[pos] === '"') pos++ // skip opening quote
215
+ while (
216
+ pos < str.length &&
217
+ !(str[pos] === '"' && str[pos + 1] === ">" && str[pos + 2] === ">")
218
+ ) {
219
+ if (str[pos] === "\\") pos++
220
+ pos++
221
+ }
222
+ return pos + 3
223
+ } else {
224
+ let depth = 1
225
+ while (depth > 0 && pos < str.length) {
226
+ if (str[pos] === "<" && str[pos + 1] === "<") {
227
+ depth++
228
+ pos += 2
229
+ } else if (str[pos] === ">" && str[pos + 1] === ">") {
230
+ depth--
231
+ pos += 2
232
+ } else {
233
+ pos++
234
+ }
235
+ }
236
+ return pos
237
+ }
238
+ } else if (str[pos] === "[") {
239
+ // List
240
+ pos++
241
+ let listIndex = 0
242
+
243
+ while (pos < str.length) {
244
+ while (pos < str.length && /\s/.test(str[pos])) pos++
245
+ if (str[pos] === "]") return pos + 1
246
+
247
+ pos = scanValue(str, pos, [...path, listIndex])
248
+ listIndex++
249
+
250
+ while (pos < str.length && /\s/.test(str[pos])) pos++
251
+ if (str[pos] === ",") pos++
252
+ }
253
+ } else {
254
+ // Skip other values
255
+ while (pos < str.length && !/[\s,\]}=>]/.test(str[pos])) {
256
+ pos++
257
+ }
258
+ return pos
259
+ }
260
+
261
+ return pos
262
+ }
263
+
264
+ scanValue(rawStr, 0, [])
265
+ return typeMap
266
+ }
267
+
268
+ // Parser that uses type information
269
+ class TypeAwareParser {
270
+ constructor(str, typeMap) {
271
+ this.str = str
272
+ this.typeMap = typeMap
273
+ this.pos = 0
274
+ this.path = []
275
+ }
276
+
277
+ parse() {
278
+ const result = this.parseValue()
279
+ this.skipWhitespace()
280
+ if (this.pos < this.str.length) {
281
+ throw new Error(`Unexpected content at position ${this.pos}`)
282
+ }
283
+ return result
284
+ }
285
+
286
+ peek(offset = 0) {
287
+ return this.str[this.pos + offset] || ""
288
+ }
289
+
290
+ advance(count = 1) {
291
+ this.pos += count
292
+ }
293
+
294
+ skipWhitespace() {
295
+ while (this.pos < this.str.length && /\s/.test(this.peek())) {
296
+ this.advance()
297
+ }
298
+ }
299
+
300
+ parseValue() {
301
+ this.skipWhitespace()
302
+
303
+ const ch = this.peek()
304
+ const ch2 = this.peek(1)
305
+
306
+ if (ch === "#" && ch2 === "{") {
307
+ return this.parseMap()
308
+ }
309
+
310
+ if (ch === "<" && ch2 === "<") {
311
+ return this.parseBinary()
312
+ }
313
+
314
+ if (ch === "[") {
315
+ return this.parseList()
316
+ }
317
+
318
+ if (ch === "'") {
319
+ return this.parseQuotedAtom()
320
+ }
321
+
322
+ if (ch === '"') {
323
+ return this.parseQuotedString()
324
+ }
325
+
326
+ return this.parseAtomOrNumber()
327
+ }
328
+
329
+ parseMap() {
330
+ this.advance(2) // skip #{
331
+ const map = {}
332
+ let mapIndex = 0
333
+
334
+ while (true) {
335
+ this.skipWhitespace()
336
+
337
+ if (this.peek() === "}") {
338
+ this.advance()
339
+ break
340
+ }
341
+
342
+ this.path.push(mapIndex)
343
+ const key = this.parseValue()
344
+ this.path.pop()
345
+
346
+ this.skipWhitespace()
347
+
348
+ if (this.peek() !== "=" || this.peek(1) !== ">") {
349
+ throw new Error("Expected =>")
350
+ }
351
+ this.advance(2)
352
+
353
+ this.skipWhitespace()
354
+
355
+ this.path.push(mapIndex)
356
+ const value = this.parseValue()
357
+ this.path.pop()
358
+
359
+ const jsKey =
360
+ key instanceof Buffer
361
+ ? key.toString("utf8")
362
+ : typeof key === "symbol"
363
+ ? Symbol.keyFor(key) || String(key)
364
+ : String(key)
365
+
366
+ map[jsKey] = value
367
+ mapIndex++
368
+
369
+ this.skipWhitespace()
370
+ if (this.peek() === ",") {
371
+ this.advance()
372
+ }
373
+ }
374
+
375
+ return map
376
+ }
377
+
378
+ parseBinary() {
379
+ const currentPath = this.path.join(".")
380
+ const type = this.typeMap.get(currentPath) || "binary"
381
+
382
+ // Parse the formatted version (always byte format)
383
+ this.advance(2) // skip <<
384
+
385
+ if (this.peek() === ">" && this.peek(1) === ">") {
386
+ this.advance(2)
387
+ return Buffer.alloc(0)
388
+ }
389
+
390
+ const bytes = []
391
+ while (!(this.peek() === ">" && this.peek(1) === ">")) {
392
+ this.skipWhitespace()
393
+
394
+ let num = ""
395
+ while (/\d/.test(this.peek())) {
396
+ num += this.peek()
397
+ this.advance()
398
+ }
399
+
400
+ if (num) bytes.push(parseInt(num, 10))
401
+
402
+ this.skipWhitespace()
403
+ if (this.peek() === ",") this.advance()
404
+ }
405
+
406
+ this.advance(2) // skip >>
407
+
408
+ const buffer = Buffer.from(bytes)
409
+
410
+ // If it was a string literal in raw, convert to string
411
+ if (type === "string") {
412
+ return buffer.toString("utf8")
413
+ }
414
+
415
+ // For binaries that are not explicitly marked as strings, check if they're valid UTF-8
416
+ // that contains reasonable string content (no control characters except \t, \n, \r)
417
+ try {
418
+ const str = buffer.toString("utf8")
419
+ // Check if the string round-trips correctly
420
+ if (Buffer.from(str, "utf8").equals(buffer)) {
421
+ // It's valid UTF-8, but also check if it contains reasonable characters
422
+ let hasReasonableChars = true
423
+ for (let i = 0; i < str.length; i++) {
424
+ const code = str.charCodeAt(i)
425
+ // Allow printable chars, space, tab, newline, carriage return, and Unicode
426
+ if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) {
427
+ hasReasonableChars = false
428
+ break
429
+ }
430
+ }
431
+
432
+ if (hasReasonableChars) {
433
+ return str
434
+ }
435
+ }
436
+ } catch (e) {
437
+ // Not valid UTF-8, keep as buffer
438
+ }
439
+
440
+ return buffer
441
+ }
442
+
443
+ parseQuotedString() {
444
+ this.advance() // skip opening "
445
+ let str = ""
446
+
447
+ while (this.peek() !== '"') {
448
+ if (this.peek() === "\\") {
449
+ this.advance()
450
+ const escaped = this.peek()
451
+ switch (escaped) {
452
+ case '"':
453
+ str += '"'
454
+ break
455
+ case "\\":
456
+ str += "\\"
457
+ break
458
+ case "n":
459
+ str += "\n"
460
+ break
461
+ case "r":
462
+ str += "\r"
463
+ break
464
+ case "t":
465
+ str += "\t"
466
+ break
467
+ default:
468
+ str += escaped
469
+ }
470
+ this.advance()
471
+ } else {
472
+ str += this.peek()
473
+ this.advance()
474
+ }
475
+ }
476
+
477
+ this.advance() // skip closing "
478
+ return str
479
+ }
480
+
481
+ parseList() {
482
+ this.advance() // skip [
483
+ const list = []
484
+ let listIndex = 0
485
+
486
+ while (true) {
487
+ this.skipWhitespace()
488
+
489
+ if (this.peek() === "]") {
490
+ this.advance()
491
+ break
492
+ }
493
+
494
+ this.path.push(listIndex)
495
+ list.push(this.parseValue())
496
+ this.path.pop()
497
+
498
+ listIndex++
499
+
500
+ this.skipWhitespace()
501
+ if (this.peek() === ",") {
502
+ this.advance()
503
+ }
504
+ }
505
+
506
+ return list
507
+ }
508
+
509
+ parseQuotedAtom() {
510
+ this.advance() // skip '
511
+
512
+ let atom = ""
513
+ while (this.peek() !== "'") {
514
+ if (this.peek() === "\\") {
515
+ this.advance()
516
+ const escaped = this.peek()
517
+ switch (escaped) {
518
+ case "'":
519
+ atom += "'"
520
+ break
521
+ case "\\":
522
+ atom += "\\"
523
+ break
524
+ case "n":
525
+ atom += "\n"
526
+ break
527
+ case "r":
528
+ atom += "\r"
529
+ break
530
+ case "t":
531
+ atom += "\t"
532
+ break
533
+ default:
534
+ atom += escaped
535
+ }
536
+ this.advance()
537
+ } else {
538
+ atom += this.peek()
539
+ this.advance()
540
+ }
541
+ }
542
+
543
+ this.advance() // skip closing '
544
+
545
+ if (atom === "null") return null
546
+ if (atom === "true") return true
547
+ if (atom === "false") return false
548
+
549
+ return Symbol.for(atom)
550
+ }
551
+
552
+ parseAtomOrNumber() {
553
+ let token = ""
554
+
555
+ // Parse unquoted atom/number
556
+ while (this.pos < this.str.length) {
557
+ const ch = this.peek()
558
+
559
+ if (
560
+ ch === "," ||
561
+ ch === "]" ||
562
+ ch === "}" ||
563
+ ch === ")" ||
564
+ (ch === "=" && this.peek(1) === ">")
565
+ ) {
566
+ break
567
+ }
568
+
569
+ if (/\s/.test(ch)) {
570
+ let i = this.pos
571
+ while (i < this.str.length && /\s/.test(this.str[i])) i++
572
+
573
+ if (i >= this.str.length) break
574
+
575
+ const nextCh = this.str[i]
576
+ if (
577
+ nextCh === "," ||
578
+ nextCh === "]" ||
579
+ nextCh === "}" ||
580
+ nextCh === ")" ||
581
+ (nextCh === "=" && i + 1 < this.str.length && this.str[i + 1] === ">")
582
+ ) {
583
+ break
584
+ }
585
+ }
586
+
587
+ token += ch
588
+ this.advance()
589
+ }
590
+
591
+ token = token.trim()
592
+
593
+ if (token === "null") return null
594
+ if (token === "true") return true
595
+ if (token === "false") return false
596
+
597
+ if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(token)) {
598
+ return parseFloat(token)
599
+ }
600
+
601
+ return Symbol.for(token)
602
+ }
603
+ }
604
+
605
+ // Parser implementation
606
+ class ErlangParser {
607
+ constructor(str, binaryMode = false) {
608
+ this.str = str
609
+ this.pos = 0
610
+ this.binaryMode = binaryMode
611
+ }
612
+
613
+ parse() {
614
+ const result = this.parseValue()
615
+ this.skipWhitespace()
616
+ if (this.pos < this.str.length) {
617
+ throw new Error(
618
+ `Unexpected content at position ${this.pos}: ${this.str.slice(this.pos, this.pos + 50)}`
619
+ )
620
+ }
621
+ return result
622
+ }
623
+
624
+ peek(offset = 0) {
625
+ return this.str[this.pos + offset] || ""
626
+ }
627
+
628
+ advance(count = 1) {
629
+ this.pos += count
630
+ }
631
+
632
+ skipWhitespace() {
633
+ while (this.pos < this.str.length && /\s/.test(this.peek())) {
634
+ this.advance()
635
+ }
636
+ }
637
+
638
+ parseValue() {
639
+ this.skipWhitespace()
640
+
641
+ const ch = this.peek()
642
+ const ch2 = this.peek(1)
643
+
644
+ // Map: #{...}
645
+ if (ch === "#" && ch2 === "{") {
646
+ return this.parseMap()
647
+ }
648
+
649
+ // Binary: <<...>>
650
+ if (ch === "<" && ch2 === "<") {
651
+ return this.parseBinary()
652
+ }
653
+
654
+ // List: [...]
655
+ if (ch === "[") {
656
+ return this.parseList()
657
+ }
658
+
659
+ // Quoted atom: 'atom'
660
+ if (ch === "'") {
661
+ return this.parseQuotedAtom()
662
+ }
663
+
664
+ // Quoted string: "..."
665
+ if (ch === '"') {
666
+ return this.parseQuotedString()
667
+ }
668
+
669
+ // Number or unquoted atom
670
+ return this.parseAtomOrNumber()
671
+ }
672
+
673
+ parseMap() {
674
+ this.advance(2) // skip #{
675
+ const map = {}
676
+
677
+ while (true) {
678
+ this.skipWhitespace()
679
+
680
+ if (this.peek() === "}") {
681
+ this.advance()
682
+ break
683
+ }
684
+
685
+ // Parse key
686
+ const key = this.parseValue()
687
+ this.skipWhitespace()
688
+
689
+ // Expect =>
690
+ if (this.peek() !== "=" || this.peek(1) !== ">") {
691
+ throw new Error(`Expected => at position ${this.pos}`)
692
+ }
693
+ this.advance(2)
694
+
695
+ this.skipWhitespace()
696
+
697
+ // Parse value
698
+ const value = this.parseValue()
699
+
700
+ // Convert key to string
701
+ let jsKey
702
+ if (key instanceof Buffer) {
703
+ jsKey = key.toString("utf8")
704
+ } else if (typeof key === "symbol") {
705
+ jsKey = Symbol.keyFor(key) || key.description || String(key)
706
+ } else {
707
+ jsKey = String(key)
708
+ }
709
+
710
+ map[jsKey] = value
711
+
712
+ this.skipWhitespace()
713
+ if (this.peek() === ",") {
714
+ this.advance()
715
+ }
716
+ }
717
+
718
+ return map
719
+ }
720
+
721
+ parseBinary() {
722
+ this.advance(2) // skip <<
723
+
724
+ // String binary: <<"...">>
725
+ if (this.peek() === '"') {
726
+ this.advance() // skip "
727
+ let content = ""
728
+
729
+ while (
730
+ !(this.peek() === '"' && this.peek(1) === ">" && this.peek(2) === ">")
731
+ ) {
732
+ if (this.pos >= this.str.length) {
733
+ throw new Error("Unterminated string binary")
734
+ }
735
+
736
+ if (this.peek() === "\\") {
737
+ this.advance()
738
+ const escaped = this.peek()
739
+
740
+ // Check for octal escape sequences \NNN
741
+ if (/[0-7]/.test(escaped)) {
742
+ let octal = escaped
743
+ this.advance()
744
+
745
+ // Get up to 2 more octal digits
746
+ if (/[0-7]/.test(this.peek())) {
747
+ octal += this.peek()
748
+ this.advance()
749
+
750
+ if (/[0-7]/.test(this.peek())) {
751
+ octal += this.peek()
752
+ this.advance()
753
+ }
754
+ }
755
+
756
+ // Convert octal to character
757
+ const charCode = parseInt(octal, 8)
758
+ content += String.fromCharCode(charCode)
759
+ } else {
760
+ // Regular escape sequences
761
+ switch (escaped) {
762
+ case '"':
763
+ content += '"'
764
+ break
765
+ case "\\":
766
+ content += "\\"
767
+ break
768
+ case "n":
769
+ content += "\n"
770
+ break
771
+ case "r":
772
+ content += "\r"
773
+ break
774
+ case "t":
775
+ content += "\t"
776
+ break
777
+ default:
778
+ content += escaped
779
+ }
780
+ this.advance()
781
+ }
782
+ } else {
783
+ content += this.peek()
784
+ this.advance()
785
+ }
786
+ }
787
+
788
+ this.advance(3) // skip ">>
789
+
790
+ // Check for structured field format
791
+ if (
792
+ content.startsWith(":") &&
793
+ content.endsWith(":") &&
794
+ content.length >= 2
795
+ ) {
796
+ if (content === "::") {
797
+ return Buffer.alloc(0)
798
+ }
799
+ try {
800
+ const base64 = content.slice(1, -1)
801
+ return Buffer.from(base64, "base64")
802
+ } catch (e) {
803
+ // Not valid base64, treat as regular string
804
+ }
805
+ }
806
+
807
+ // In binary mode, return as Buffer; otherwise check if it's printable
808
+ if (this.binaryMode) {
809
+ return Buffer.from(content, "utf8")
810
+ } else {
811
+ // Check if the content is printable
812
+ const bytes = Buffer.from(content, "utf8")
813
+ const isPrintable = Array.from(bytes).every(b => b >= 32 && b <= 126)
814
+
815
+ // If it contains non-printable characters, return as Buffer
816
+ if (!isPrintable) {
817
+ return bytes
818
+ }
819
+
820
+ // Otherwise return as string
821
+ return content
822
+ }
823
+ }
824
+
825
+ // Empty binary: <<>>
826
+ if (this.peek() === ">" && this.peek(1) === ">") {
827
+ this.advance(2)
828
+ return Buffer.alloc(0)
829
+ }
830
+
831
+ // Byte binary: <<1,2,3>>
832
+ const bytes = []
833
+ while (!(this.peek() === ">" && this.peek(1) === ">")) {
834
+ if (this.pos >= this.str.length) {
835
+ throw new Error("Unterminated byte binary")
836
+ }
837
+
838
+ this.skipWhitespace()
839
+
840
+ let num = ""
841
+ while (/\d/.test(this.peek())) {
842
+ num += this.peek()
843
+ this.advance()
844
+ }
845
+
846
+ if (num) {
847
+ bytes.push(parseInt(num, 10))
848
+ }
849
+
850
+ this.skipWhitespace()
851
+ if (this.peek() === ",") {
852
+ this.advance()
853
+ }
854
+ }
855
+
856
+ this.advance(2) // skip >>
857
+ return Buffer.from(bytes)
858
+ }
859
+
860
+ parseQuotedString() {
861
+ this.advance() // skip "
862
+ let str = ""
863
+
864
+ while (this.peek() !== '"') {
865
+ if (this.peek() === "\\") {
866
+ this.advance()
867
+ const escaped = this.peek()
868
+ switch (escaped) {
869
+ case '"':
870
+ str += '"'
871
+ break
872
+ case "\\":
873
+ str += "\\"
874
+ break
875
+ case "n":
876
+ str += "\n"
877
+ break
878
+ case "r":
879
+ str += "\r"
880
+ break
881
+ case "t":
882
+ str += "\t"
883
+ break
884
+ default:
885
+ str += escaped
886
+ }
887
+ this.advance()
888
+ } else {
889
+ str += this.peek()
890
+ this.advance()
891
+ }
892
+ }
893
+
894
+ this.advance() // skip closing "
895
+ return str
896
+ }
897
+
898
+ parseList() {
899
+ this.advance() // skip [
900
+ const list = []
901
+
902
+ while (true) {
903
+ this.skipWhitespace()
904
+
905
+ if (this.peek() === "]") {
906
+ this.advance()
907
+ break
908
+ }
909
+
910
+ list.push(this.parseValue())
911
+
912
+ this.skipWhitespace()
913
+ if (this.peek() === ",") {
914
+ this.advance()
915
+ }
916
+ }
917
+
918
+ return list
919
+ }
920
+
921
+ parseQuotedAtom() {
922
+ this.advance() // skip '
923
+ let atom = ""
924
+
925
+ while (this.peek() !== "'") {
926
+ if (this.pos >= this.str.length) {
927
+ throw new Error("Unterminated quoted atom")
928
+ }
929
+
930
+ if (this.peek() === "\\") {
931
+ this.advance()
932
+ const escaped = this.peek()
933
+ switch (escaped) {
934
+ case "'":
935
+ atom += "'"
936
+ break
937
+ case "\\":
938
+ atom += "\\"
939
+ break
940
+ case "n":
941
+ atom += "\n"
942
+ break // Literal newline
943
+ case "r":
944
+ atom += "\r"
945
+ break
946
+ case "t":
947
+ atom += "\t"
948
+ break
949
+ default:
950
+ atom += escaped
951
+ }
952
+ this.advance()
953
+ } else {
954
+ atom += this.peek()
955
+ this.advance()
956
+ }
957
+ }
958
+
959
+ this.advance() // skip closing '
960
+
961
+ // Special atoms that become JS primitives
962
+ if (atom === "null") return null
963
+ if (atom === "true") return true
964
+ if (atom === "false") return false
965
+
966
+ return Symbol.for(atom)
967
+ }
968
+
969
+ parseAtomOrNumber() {
970
+ const startPos = this.pos
971
+ let token = ""
972
+
973
+ // Simple approach: collect until we hit a known delimiter
974
+ while (this.pos < this.str.length) {
975
+ const ch = this.peek()
976
+ const ch2 = this.peek(1)
977
+
978
+ // Stop at these delimiters
979
+ if (ch === ",") break
980
+ if (ch === "]") break
981
+ if (ch === "}") break
982
+ if (ch === ")") break
983
+ if (ch === "=" && ch2 === ">") break
984
+
985
+ // Handle backslash specially
986
+ if (ch === "\\") {
987
+ // In unquoted atoms, backslash is just a regular character
988
+ token += ch
989
+ this.advance()
990
+
991
+ // Don't try to interpret the next character as an escape
992
+ if (this.pos < this.str.length) {
993
+ token += this.peek()
994
+ this.advance()
995
+ }
996
+ continue
997
+ }
998
+
999
+ // For whitespace, check if it's trailing
1000
+ if (/\s/.test(ch)) {
1001
+ // Scan ahead to find next non-whitespace
1002
+ let i = this.pos
1003
+ while (i < this.str.length && /\s/.test(this.str[i])) {
1004
+ i++
1005
+ }
1006
+
1007
+ // Check what comes after whitespace
1008
+ if (i >= this.str.length) {
1009
+ // Hit end of string
1010
+ break
1011
+ }
1012
+
1013
+ const nextCh = this.str[i]
1014
+ if (
1015
+ nextCh === "," ||
1016
+ nextCh === "]" ||
1017
+ nextCh === "}" ||
1018
+ nextCh === ")"
1019
+ ) {
1020
+ // This whitespace is trailing, not part of atom
1021
+ break
1022
+ }
1023
+ if (
1024
+ nextCh === "=" &&
1025
+ i + 1 < this.str.length &&
1026
+ this.str[i + 1] === ">"
1027
+ ) {
1028
+ // Whitespace before =>
1029
+ break
1030
+ }
1031
+ }
1032
+
1033
+ // Include this character in the atom
1034
+ token += ch
1035
+ this.advance()
1036
+
1037
+ // Safety check to prevent infinite loops
1038
+ if (this.pos - startPos > 1000) {
1039
+ throw new Error(`Atom too long at position ${startPos}`)
1040
+ }
1041
+ }
1042
+
1043
+ // Trim only trailing whitespace
1044
+ token = token.trimEnd()
1045
+
1046
+ if (!token) {
1047
+ throw new Error(`Empty atom at position ${this.pos}`)
1048
+ }
1049
+
1050
+ // Handle special atoms
1051
+ if (token === "null") return null
1052
+ if (token === "true") return true
1053
+ if (token === "false") return false
1054
+
1055
+ // Try to parse as number
1056
+ if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(token)) {
1057
+ return parseFloat(token)
1058
+ }
1059
+
1060
+ // Return as atom
1061
+ return Symbol.for(token)
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Format JavaScript values as Erlang term strings
1067
+ * @param {*} value - JavaScript value
1068
+ * @returns {string} Erlang term string
1069
+ */
1070
+ export function erl_str_to(value) {
1071
+ return formatValue(value)
1072
+ }
1073
+
1074
+ // Formatter implementation
1075
+ function formatValue(value) {
1076
+ if (value === null) return "null"
1077
+ if (value === undefined) return "undefined"
1078
+ if (typeof value === "boolean") return value.toString()
1079
+
1080
+ if (typeof value === "number") {
1081
+ return value.toString()
1082
+ }
1083
+
1084
+ if (typeof value === "string") {
1085
+ // Format as string binary
1086
+ return `<<"${escapeString(value)}">>`
1087
+ }
1088
+
1089
+ if (typeof value === "symbol") {
1090
+ const key = Symbol.keyFor(value)
1091
+ const name = key || value.description || ""
1092
+
1093
+ // Special symbols
1094
+ if (name === "null") return "null"
1095
+ if (name === "true") return "true"
1096
+ if (name === "false") return "false"
1097
+ if (name === "undefined") return "undefined"
1098
+
1099
+ // Check if needs quoting
1100
+ if (/^[a-z][a-zA-Z0-9_]*$/.test(name)) {
1101
+ return name
1102
+ } else {
1103
+ return `'${escapeString(name)}'`
1104
+ }
1105
+ }
1106
+
1107
+ if (value instanceof Buffer || value instanceof Uint8Array) {
1108
+ const bytes = Array.from(value)
1109
+
1110
+ // Check if it's printable
1111
+ const isPrintable = bytes.every(b => b >= 32 && b <= 126)
1112
+
1113
+ if (isPrintable) {
1114
+ const str = Buffer.from(value).toString()
1115
+ return `<<"${escapeString(str)}">>`
1116
+ } else {
1117
+ return `<<${bytes.join(",")}>>`
1118
+ }
1119
+ }
1120
+
1121
+ if (Array.isArray(value)) {
1122
+ const items = value.map(formatValue)
1123
+ return `[${items.join(",")}]`
1124
+ }
1125
+
1126
+ if (typeof value === "object" && value !== null) {
1127
+ const entries = Object.entries(value).map(([k, v]) => {
1128
+ const key = `<<"${escapeString(k)}">>`
1129
+ return `${key} => ${formatValue(v)}`
1130
+ })
1131
+ return `#{${entries.join(",")}}`
1132
+ }
1133
+
1134
+ return String(value)
1135
+ }
1136
+
1137
+ function escapeString(str) {
1138
+ return str
1139
+ .replace(/\\/g, "\\\\")
1140
+ .replace(/"/g, '\\"')
1141
+ .replace(/\n/g, "\\n")
1142
+ .replace(/\r/g, "\\r")
1143
+ .replace(/\t/g, "\\t")
1144
+ }