@o3co/ts.hocon 1.1.0 → 1.3.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.
package/dist/index.js CHANGED
@@ -1,5 +1,26 @@
1
1
  // src/coerce.ts
2
2
  var DECIMAL_NUMBER_RE = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
3
+ function isHoconWs(cp) {
4
+ if (cp === 9 || cp === 10 || cp === 11 || cp === 12 || cp === 13) return true;
5
+ if (cp >= 28 && cp <= 31) return true;
6
+ if (cp === 32 || cp === 160 || cp === 65279) return true;
7
+ if (cp === 5760) return true;
8
+ if (cp >= 8192 && cp <= 8202) return true;
9
+ if (cp === 8232 || cp === 8233 || cp === 8239 || cp === 8287) return true;
10
+ if (cp === 12288) return true;
11
+ return false;
12
+ }
13
+ function trimHoconWs(s) {
14
+ let start = 0;
15
+ let end = s.length;
16
+ while (start < end && isHoconWs(s.charCodeAt(start))) {
17
+ start++;
18
+ }
19
+ while (end > start && isHoconWs(s.charCodeAt(end - 1))) {
20
+ end--;
21
+ }
22
+ return s.slice(start, end);
23
+ }
3
24
  var TRUTHY = /* @__PURE__ */ new Set(["true", "yes", "on"]);
4
25
  var FALSY = /* @__PURE__ */ new Set(["false", "no", "off"]);
5
26
  function coerceBoolean(value) {
@@ -45,22 +66,29 @@ var OUTPUT_DURATION_UNITS = {
45
66
  d: 864e5
46
67
  };
47
68
  function parseDuration(value, outputUnit = "ms") {
48
- const trimmed = value.trim();
69
+ const trimmed = trimHoconWs(value);
49
70
  let i = 0;
50
71
  while (i < trimmed.length) {
51
72
  const ch = trimmed[i];
52
- if (ch !== "-" && ch !== "." && (ch < "0" || ch > "9")) break;
73
+ if (i === 0 && (ch === "-" || ch === "+")) {
74
+ i++;
75
+ continue;
76
+ }
77
+ if (ch !== "." && (ch < "0" || ch > "9")) break;
53
78
  i++;
54
79
  }
55
80
  if (i === 0) return NaN;
56
81
  const num = Number(trimmed.slice(0, i));
57
82
  if (Number.isNaN(num)) return NaN;
58
- const unit = trimmed.slice(i).trim().toLowerCase();
83
+ const unit = trimHoconWs(trimmed.slice(i)).toLowerCase();
84
+ const divisor = OUTPUT_DURATION_UNITS[outputUnit];
85
+ if (divisor === void 0) return NaN;
86
+ if (unit === "") {
87
+ return num / divisor;
88
+ }
59
89
  const mult = DURATION_UNITS[unit];
60
90
  if (mult === void 0) return NaN;
61
91
  const ms = num * mult;
62
- const divisor = OUTPUT_DURATION_UNITS[outputUnit];
63
- if (divisor === void 0) return NaN;
64
92
  return ms / divisor;
65
93
  }
66
94
  var BYTE_UNITS = {
@@ -91,6 +119,22 @@ var BYTE_UNITS = {
91
119
  TiB: 1099511627776,
92
120
  tebibyte: 1099511627776,
93
121
  tebibytes: 1099511627776,
122
+ // S21.4 — HOCON.md L1385: single-letter abbreviations → powers of two (java -Xmx convention).
123
+ // Lightbend typesafe-config 1.4.3 verified: 1K=1024, 1M=1048576, etc.
124
+ // Both upper- and lowercase accepted per Lightbend behaviour.
125
+ // Z/Y (2^70/2^80) are deferred — they overflow MAX_SAFE_INTEGER and require BigInt accessor.
126
+ K: 1024,
127
+ k: 1024,
128
+ M: 1024 ** 2,
129
+ m: 1024 ** 2,
130
+ G: 1024 ** 3,
131
+ g: 1024 ** 3,
132
+ T: 1024 ** 4,
133
+ t: 1024 ** 4,
134
+ P: 1024 ** 5,
135
+ p: 1024 ** 5,
136
+ E: 1024 ** 6,
137
+ e: 1024 ** 6,
94
138
  // lowercase short-form aliases
95
139
  b: 1,
96
140
  kb: 1e3,
@@ -114,25 +158,39 @@ var OUTPUT_BYTE_UNITS = {
114
158
  TiB: 1099511627776
115
159
  };
116
160
  function parseBytes(value, outputUnit = "B") {
117
- const trimmed = value.trim();
161
+ const trimmed = trimHoconWs(value);
118
162
  let i = 0;
119
163
  while (i < trimmed.length) {
120
164
  const ch = trimmed[i];
121
- if (ch !== "-" && ch !== "." && (ch < "0" || ch > "9")) break;
165
+ if (i === 0 && (ch === "-" || ch === "+")) {
166
+ i++;
167
+ continue;
168
+ }
169
+ if (ch !== "." && (ch < "0" || ch > "9")) break;
122
170
  i++;
123
171
  }
124
172
  if (i === 0) return NaN;
125
173
  const num = Number(trimmed.slice(0, i));
126
174
  if (Number.isNaN(num)) return NaN;
127
- const unit = trimmed.slice(i).trim();
175
+ const unit = trimHoconWs(trimmed.slice(i));
176
+ const divisor = OUTPUT_BYTE_UNITS[outputUnit];
177
+ if (divisor === void 0) return NaN;
178
+ if (unit === "") {
179
+ const bytes2 = Math.trunc(num);
180
+ if (Math.abs(bytes2) > Number.MAX_SAFE_INTEGER) {
181
+ throw new RangeError("byte size overflows representable range (max 2^53-1 bytes)");
182
+ }
183
+ return bytes2 / divisor;
184
+ }
128
185
  let mult = BYTE_UNITS[unit];
129
186
  if (mult === void 0) {
130
187
  mult = BYTE_UNITS[unit.toLowerCase()];
131
188
  }
132
189
  if (mult === void 0) return NaN;
133
190
  const bytes = num * mult;
134
- const divisor = OUTPUT_BYTE_UNITS[outputUnit];
135
- if (divisor === void 0) return NaN;
191
+ if (Math.abs(bytes) > Number.MAX_SAFE_INTEGER) {
192
+ throw new RangeError("byte size overflows representable range (max 2^53-1 bytes)");
193
+ }
136
194
  const result = bytes / divisor;
137
195
  return outputUnit === "B" ? Math.round(result) : result;
138
196
  }
@@ -165,6 +223,23 @@ var ConfigError = class extends Error {
165
223
  }
166
224
  };
167
225
 
226
+ // src/value/numeric-array.ts
227
+ function numericObjectToArray(value) {
228
+ if (value.kind !== "object") return null;
229
+ if (value.fields.size === 0) return null;
230
+ const canonicalInt = /^(0|[1-9][0-9]*)$/;
231
+ const eligible = [];
232
+ for (const [key, val] of value.fields) {
233
+ if (!canonicalInt.test(key)) continue;
234
+ const n = Number(key);
235
+ if (!Number.isInteger(n) || n > 2147483647) continue;
236
+ eligible.push({ n, v: val });
237
+ }
238
+ if (eligible.length === 0) return null;
239
+ eligible.sort((a, b) => a.n - b.n);
240
+ return eligible.map((e) => e.v);
241
+ }
242
+
168
243
  // src/config.ts
169
244
  var Config = class _Config {
170
245
  constructor(root) {
@@ -207,6 +282,7 @@ var Config = class _Config {
207
282
  }
208
283
  const result = parseBytes(v.raw, unit);
209
284
  if (Number.isNaN(result)) throw new ConfigError(`invalid byte size at ${path2}: ${JSON.stringify(v.raw)}`, path2);
285
+ if (result < 0) throw new ConfigError(`byte size must be non-negative at ${path2}: ${JSON.stringify(v.raw)}`, path2);
210
286
  return result;
211
287
  }
212
288
  getConfig(path2) {
@@ -218,6 +294,10 @@ var Config = class _Config {
218
294
  getList(path2) {
219
295
  const v = this.lookupNode(path2);
220
296
  if (v === void 0) throw new ConfigError(`path not found: ${path2}`, path2);
297
+ if (v.kind === "object") {
298
+ const converted = numericObjectToArray(v);
299
+ if (converted !== null) return converted.map(hoconToJs);
300
+ }
221
301
  if (v.kind !== "array") throw new ConfigError(`expected array at ${path2}`, path2);
222
302
  return v.items.map(hoconToJs);
223
303
  }
@@ -330,6 +410,20 @@ function deepMergeHocon(receiver, fallback) {
330
410
  }
331
411
 
332
412
  // src/internal/lexer/lexer.ts
413
+ function isHoconWhitespace(ch) {
414
+ const cp = ch.codePointAt(0) ?? -1;
415
+ if (cp === 9 || cp === 10 || cp === 11 || cp === 12 || cp === 13) return true;
416
+ if (cp >= 28 && cp <= 31) return true;
417
+ if (cp === 32 || cp === 160 || cp === 65279) return true;
418
+ if (cp === 5760) return true;
419
+ if (cp >= 8192 && cp <= 8202) return true;
420
+ if (cp === 8232 || cp === 8233 || cp === 8239 || cp === 8287) return true;
421
+ if (cp === 12288) return true;
422
+ return false;
423
+ }
424
+ function isHoconNewline(ch) {
425
+ return ch === "\n";
426
+ }
333
427
  var SINGLE_CHAR_TOKENS = {
334
428
  "{": "lbrace",
335
429
  "}": "rbrace",
@@ -352,18 +446,18 @@ var Lexer = class {
352
446
  while (this.pos < this.input.length) {
353
447
  const sl = this.line, sc = this.col;
354
448
  const ch = this.peek();
355
- if (ch === " " || ch === " " || ch === "\r") {
356
- this.advance();
357
- this.hadSpace = true;
358
- continue;
359
- }
360
- if (ch === "\n") {
449
+ if (isHoconNewline(ch)) {
361
450
  this.advance();
362
451
  if (this.tokens[this.tokens.length - 1]?.kind !== "newline") {
363
452
  this.push("newline", "\n", sl, sc);
364
453
  }
365
454
  continue;
366
455
  }
456
+ if (isHoconWhitespace(ch)) {
457
+ this.advance();
458
+ this.hadSpace = true;
459
+ continue;
460
+ }
367
461
  if (ch === "/" && this.peek(1) === "/") {
368
462
  while (this.pos < this.input.length && this.peek() !== "\n") this.advance();
369
463
  this.hadSpace = true;
@@ -393,16 +487,16 @@ var Lexer = class {
393
487
  if (ch === "$" && this.peek(1) === "{") {
394
488
  this.advance();
395
489
  this.advance();
396
- const optional = this.peek() === "?";
397
- if (optional) this.advance();
398
- let path2 = "";
399
- while (this.pos < this.input.length && this.peek() !== "}") {
400
- if (this.peek() === "\n") throw new ParseError("unterminated substitution", sl, sc);
401
- path2 += this.advance();
402
- }
403
- if (this.peek() !== "}") throw new ParseError("unterminated substitution", sl, sc);
404
- this.advance();
405
- this.push(optional ? "opt_subst" : "subst", path2.trim(), sl, sc);
490
+ const payload = this.parseSubstBody(sl, sc);
491
+ const value = payload.segments.map((s) => {
492
+ const t = s.text;
493
+ if (t === "" || t.includes(".") || t.includes(" ") || t.includes(" ") || t.includes('"') || t.includes("\\") || t !== t.trim()) {
494
+ const escaped = t.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
495
+ return `"${escaped}"`;
496
+ }
497
+ return t;
498
+ }).join(".");
499
+ this.pushSubst(payload, value, sl, sc);
406
500
  continue;
407
501
  }
408
502
  if (ch === '"' && this.peek(1) === '"' && this.peek(2) === '"') {
@@ -437,58 +531,7 @@ var Lexer = class {
437
531
  }
438
532
  if (ch === '"') {
439
533
  this.advance();
440
- let value = "";
441
- while (this.pos < this.input.length && this.peek() !== '"') {
442
- if (this.peek() === "\n") throw new ParseError("unterminated string", sl, sc);
443
- if (this.peek() === "\\") {
444
- this.advance();
445
- const esc = this.advance();
446
- switch (esc) {
447
- case "n":
448
- value += "\n";
449
- break;
450
- case "t":
451
- value += " ";
452
- break;
453
- case "r":
454
- value += "\r";
455
- break;
456
- case '"':
457
- value += '"';
458
- break;
459
- case "\\":
460
- value += "\\";
461
- break;
462
- case "/":
463
- value += "/";
464
- break;
465
- case "b":
466
- value += "\b";
467
- break;
468
- case "f":
469
- value += "\f";
470
- break;
471
- case "u": {
472
- if (this.pos + 4 > this.input.length) {
473
- throw new ParseError("Invalid unicode escape: not enough characters", this.line, this.col);
474
- }
475
- const hex = this.input.slice(this.pos, this.pos + 4);
476
- if (!/^[0-9a-fA-F]{4}$/.test(hex)) {
477
- throw new ParseError(`Invalid unicode escape: \\u${hex}`, this.line, this.col);
478
- }
479
- value += String.fromCharCode(parseInt(hex, 16));
480
- for (let i = 0; i < 4; i++) this.advance();
481
- break;
482
- }
483
- default:
484
- throw new ParseError(`Unknown escape sequence: \\${esc}`, this.line, this.col);
485
- }
486
- } else {
487
- value += this.advance();
488
- }
489
- }
490
- if (this.peek() !== '"') throw new ParseError("unterminated string", sl, sc);
491
- this.advance();
534
+ const value = this.readQuotedStringBody(sl, sc);
492
535
  this.push("string", value, sl, sc, true);
493
536
  continue;
494
537
  }
@@ -522,22 +565,255 @@ var Lexer = class {
522
565
  this.tokens.push({ kind, value, line: l, col: c, isQuoted, precedingSpace: this.hadSpace });
523
566
  this.hadSpace = false;
524
567
  }
568
+ pushSubst(payload, value, l, c) {
569
+ this.tokens.push({ kind: "subst", value, line: l, col: c, isQuoted: false, precedingSpace: this.hadSpace, subst: payload });
570
+ this.hadSpace = false;
571
+ }
572
+ /**
573
+ * Read body of a quoted string. Opening `"` must already be consumed.
574
+ * Returns decoded string. Throws ParseError on unterminated/invalid-escape.
575
+ * openLine/openCol are the position of the opening `"` for error reporting.
576
+ */
577
+ readQuotedStringBody(openLine, openCol) {
578
+ let value = "";
579
+ while (this.pos < this.input.length && this.peek() !== '"') {
580
+ if (this.peek() === "\n") throw new ParseError("unterminated string", openLine, openCol);
581
+ if (this.peek() === "\\") {
582
+ const escCol = this.col;
583
+ this.advance();
584
+ if (this.pos >= this.input.length) {
585
+ throw new ParseError("unterminated string", openLine, openCol);
586
+ }
587
+ const esc = this.advance();
588
+ switch (esc) {
589
+ case "n":
590
+ value += "\n";
591
+ break;
592
+ case "t":
593
+ value += " ";
594
+ break;
595
+ case "r":
596
+ value += "\r";
597
+ break;
598
+ case '"':
599
+ value += '"';
600
+ break;
601
+ case "\\":
602
+ value += "\\";
603
+ break;
604
+ case "/":
605
+ value += "/";
606
+ break;
607
+ case "b":
608
+ value += "\b";
609
+ break;
610
+ case "f":
611
+ value += "\f";
612
+ break;
613
+ case "u": {
614
+ if (this.pos + 4 > this.input.length) {
615
+ throw new ParseError("invalid unicode escape", openLine, escCol);
616
+ }
617
+ const hex = this.input.slice(this.pos, this.pos + 4);
618
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) {
619
+ throw new ParseError("invalid unicode escape", openLine, escCol);
620
+ }
621
+ const code = parseInt(hex, 16);
622
+ value += String.fromCharCode(code);
623
+ for (let i = 0; i < 4; i++) this.advance();
624
+ break;
625
+ }
626
+ default:
627
+ throw new ParseError(`unknown escape sequence: \\${esc}`, openLine, escCol);
628
+ }
629
+ } else {
630
+ value += this.advance();
631
+ }
632
+ }
633
+ if (this.pos >= this.input.length || this.peek() !== '"') {
634
+ throw new ParseError("unterminated string", openLine, openCol);
635
+ }
636
+ this.advance();
637
+ return value;
638
+ }
639
+ /**
640
+ * Parse the body of a `${...}` substitution (called after `${` has been consumed).
641
+ * Implements Appendix A state machine from the cross-language spec.
642
+ *
643
+ * S13c extension: if the segment-collection loop encounters `[`, it delegates to
644
+ * `parseListSuffix()` which consumes the literal `[]` and sets `listSuffix=true`.
645
+ * ASCII space/tab before `[` is buffered in `pendingWs` and discarded (E7).
646
+ */
647
+ parseSubstBody(startLine, startCol) {
648
+ let optional = false;
649
+ if (this.peek() === "?") {
650
+ this.advance();
651
+ optional = true;
652
+ }
653
+ let curText = "";
654
+ let curStarted = false;
655
+ let curLine = 0;
656
+ let curCol = 0;
657
+ let pendingWs = "";
658
+ const segments = [];
659
+ let lastDot = null;
660
+ let listSuffix = false;
661
+ while (true) {
662
+ if (this.pos >= this.input.length) {
663
+ throw new ParseError("unterminated substitution", startLine, startCol);
664
+ }
665
+ const ch = this.peek();
666
+ if (ch === "}") {
667
+ this.advance();
668
+ pendingWs = "";
669
+ break;
670
+ } else if (ch === "[") {
671
+ if (!curStarted) {
672
+ throw new ParseError("empty segment before '[]' suffix in substitution path", startLine, this.col);
673
+ }
674
+ for (const w of pendingWs) {
675
+ if (w !== " " && w !== " ") {
676
+ throw new ParseError(
677
+ `only ASCII space or tab allowed between substitution path and '[]' suffix (got ${JSON.stringify(w)}, HOCON extra-spec E7)`,
678
+ startLine,
679
+ this.col
680
+ );
681
+ }
682
+ }
683
+ segments.push({ text: curText, line: curLine, col: curCol });
684
+ pendingWs = "";
685
+ listSuffix = this.parseListSuffix(startLine);
686
+ break;
687
+ } else if (ch === '"') {
688
+ const qLine = startLine;
689
+ const qCol = this.col;
690
+ if (curStarted) {
691
+ curText += pendingWs;
692
+ }
693
+ pendingWs = "";
694
+ this.advance();
695
+ const decoded = this.readQuotedStringBody(qLine, qCol);
696
+ curText += decoded;
697
+ if (!curStarted) {
698
+ curLine = qLine;
699
+ curCol = qCol;
700
+ curStarted = true;
701
+ }
702
+ } else if (isUnquotedSubstChar(ch)) {
703
+ if (ch === "-" && !curStarted && !isDecimalDigit(this.peek(1))) {
704
+ const after = this.peek(1) === "" ? "EOF" : JSON.stringify(this.peek(1));
705
+ throw new ParseError(
706
+ `unquoted path segment cannot begin with '-' unless followed by a digit (got '-' then ${after}, HOCON.md L270-276)`,
707
+ startLine,
708
+ this.col
709
+ );
710
+ }
711
+ const uCol = this.col;
712
+ if (curStarted) {
713
+ curText += pendingWs;
714
+ }
715
+ pendingWs = "";
716
+ if (!curStarted) {
717
+ curLine = startLine;
718
+ curCol = uCol;
719
+ curStarted = true;
720
+ }
721
+ while (this.pos < this.input.length && isUnquotedSubstChar(this.peek())) {
722
+ curText += this.advance();
723
+ }
724
+ } else if (ch === ".") {
725
+ const dotCol = this.col;
726
+ pendingWs = "";
727
+ if (!curStarted) {
728
+ throw new ParseError("empty segment in path", startLine, dotCol);
729
+ }
730
+ segments.push({ text: curText, line: curLine, col: curCol });
731
+ curText = "";
732
+ curStarted = false;
733
+ curLine = 0;
734
+ curCol = 0;
735
+ lastDot = [startLine, dotCol];
736
+ this.advance();
737
+ } else if (isHoconNewline(ch)) {
738
+ throw new ParseError("unterminated substitution", startLine, startCol);
739
+ } else if (isHoconWhitespace(ch)) {
740
+ pendingWs += ch;
741
+ this.advance();
742
+ } else {
743
+ throw new ParseError(`unexpected character in substitution path: ${JSON.stringify(ch)}`, startLine, this.col);
744
+ }
745
+ }
746
+ if (!listSuffix) {
747
+ if (curStarted) {
748
+ segments.push({ text: curText, line: curLine, col: curCol });
749
+ } else if (segments.length === 0) {
750
+ throw new ParseError("empty substitution path", startLine, startCol);
751
+ } else {
752
+ const [errLine, errCol] = lastDot ?? [startLine, startCol];
753
+ throw new ParseError("empty segment in path", errLine, errCol);
754
+ }
755
+ }
756
+ return { segments, optional, listSuffix };
757
+ }
758
+ /**
759
+ * Consume the literal `[]` suffix inside a substitution body.
760
+ * Called after the `[` has been detected (but not yet consumed).
761
+ * Requires the next character to be `[`, then exactly `]`, then `}`.
762
+ * Whitespace inside `[]` is not permitted (per spec Decision §1).
763
+ * Returns true (always — only called when listSuffix path is taken).
764
+ */
765
+ parseListSuffix(startLine) {
766
+ this.advance();
767
+ const afterBracket = this.peek();
768
+ if (afterBracket !== "]") {
769
+ const desc = afterBracket === "" ? "EOF" : JSON.stringify(afterBracket);
770
+ throw new ParseError(
771
+ `expected ']' after '[' in substitution list suffix (got ${desc})`,
772
+ startLine,
773
+ this.col
774
+ );
775
+ }
776
+ this.advance();
777
+ if (this.peek() !== "}") {
778
+ const desc = this.peek() === "" ? "EOF" : JSON.stringify(this.peek());
779
+ throw new ParseError(
780
+ `expected '}' after '[]' in substitution list suffix (got ${desc})`,
781
+ startLine,
782
+ this.col
783
+ );
784
+ }
785
+ this.advance();
786
+ return true;
787
+ }
525
788
  };
526
789
  function tokenize(input) {
527
790
  return new Lexer(input).tokenize();
528
791
  }
792
+ function isUnquotedSubstChar(ch) {
793
+ if (ch === "" || isHoconWhitespace(ch)) return false;
794
+ if ('"\\'.includes(ch)) return false;
795
+ if ("{}[]".includes(ch)) return false;
796
+ if (":=,+#`^?!@*&$.".includes(ch)) return false;
797
+ return true;
798
+ }
799
+ function isDecimalDigit(ch) {
800
+ return ch >= "0" && ch <= "9";
801
+ }
529
802
  function isUnquotedStart(ch) {
530
- return ch !== "" && !'{}[],:=+#\n\r "$?!@*&^\\'.includes(ch);
803
+ if (ch === "" || isHoconWhitespace(ch)) return false;
804
+ if ('{}[],:=+#"$?!@*&^\\'.includes(ch)) return false;
805
+ return true;
531
806
  }
532
807
  function isUnquotedContinue(ch, nextFn) {
533
- if (ch === "" || '{}[],:=\n\r #"$?!@*&^\\'.includes(ch) || ch === " ") return false;
808
+ if (ch === "" || isHoconWhitespace(ch)) return false;
809
+ if ('{}[],:=#"$?!@*&^\\'.includes(ch)) return false;
534
810
  if (ch === "+" && nextFn() === "=") return false;
535
811
  if (ch === "/" && nextFn() === "/") return false;
536
812
  return true;
537
813
  }
538
814
 
539
815
  // src/internal/parser/parser.ts
540
- var EOF_TOKEN = { kind: "eof", value: "", line: 0, col: 0, isQuoted: false, precedingSpace: false };
816
+ var EOF_TOKEN = { kind: "eof", value: "", line: 0, col: 0, isQuoted: false, precedingSpace: false, subst: void 0 };
541
817
  var Parser = class {
542
818
  constructor(tokens) {
543
819
  this.tokens = tokens;
@@ -595,6 +871,14 @@ var Parser = class {
595
871
  const t = this.peek();
596
872
  if (t.kind === "eof" || t.kind === "rbrace") break;
597
873
  if (t.kind === "unquoted" && t.value === "include") {
874
+ const next = this.peek(1);
875
+ if (next.kind === "equals" || next.kind === "colon" || next.kind === "plus_equals" || next.kind === "lbrace") {
876
+ throw new ParseError(
877
+ `'include' is reserved at the start of a key path expression; use "include" (quoted) to use it as a key`,
878
+ t.line,
879
+ t.col
880
+ );
881
+ }
598
882
  this.advance();
599
883
  fields.push(this.parseInclude());
600
884
  this.skip("newline");
@@ -603,7 +887,14 @@ var Parser = class {
603
887
  continue;
604
888
  }
605
889
  const keyPos = this.currentPos();
606
- const key = this.parseKey();
890
+ const { segments: key, firstWasQuoted } = this.parseKey();
891
+ if (key[0] === "include" && !firstWasQuoted) {
892
+ throw new ParseError(
893
+ `'include' is reserved at the start of a key path expression; use "include".foo (quoted) or rename the key`,
894
+ keyPos.line,
895
+ keyPos.col
896
+ );
897
+ }
607
898
  this.skip("newline");
608
899
  let append = false;
609
900
  const sep = this.peek();
@@ -633,18 +924,31 @@ var Parser = class {
633
924
  parseKey() {
634
925
  const segments = [];
635
926
  let trailingDot = false;
927
+ let firstWasQuoted = false;
636
928
  while (true) {
637
929
  const t = this.peek();
638
930
  if (t.kind === "string") {
639
931
  this.advance();
932
+ if (segments.length === 0) firstWasQuoted = true;
640
933
  segments.push(t.value);
641
934
  trailingDot = false;
642
935
  } else if (t.kind === "unquoted") {
643
936
  this.advance();
644
- const parts = t.value.split(".");
937
+ const raw = t.value.startsWith(".") && segments.length > 0 && !t.precedingSpace ? t.value.slice(1) : t.value;
938
+ const parts = raw.split(".");
645
939
  const filtered = parts.filter((s) => s.length > 0);
940
+ for (const seg of filtered) {
941
+ if (seg[0] === "-" && !(seg.length >= 2 && seg[1] >= "0" && seg[1] <= "9")) {
942
+ const after = seg.length >= 2 ? JSON.stringify(seg[1]) : "EOF";
943
+ throw new ParseError(
944
+ `unquoted key segment cannot begin with '-' unless followed by a digit (got '-' then ${after} in ${JSON.stringify(seg)}, HOCON.md L270-276)`,
945
+ t.line,
946
+ t.col
947
+ );
948
+ }
949
+ }
646
950
  segments.push(...filtered);
647
- trailingDot = t.value.endsWith(".");
951
+ trailingDot = raw.endsWith(".");
648
952
  } else {
649
953
  if (segments.length === 0) throw new ParseError(`expected key, got ${t.kind}`, t.line, t.col);
650
954
  break;
@@ -656,9 +960,13 @@ var Parser = class {
656
960
  trailingDot = true;
657
961
  continue;
658
962
  }
963
+ if (next.kind === "unquoted" && next.value.startsWith(".") && !next.precedingSpace) {
964
+ trailingDot = true;
965
+ continue;
966
+ }
659
967
  break;
660
968
  }
661
- return segments;
969
+ return { segments, firstWasQuoted };
662
970
  }
663
971
  parseInclude() {
664
972
  const p = this.currentPos();
@@ -731,9 +1039,13 @@ var Parser = class {
731
1039
  } else if (t.kind === "lbracket") {
732
1040
  this.advance();
733
1041
  node = this.parseArray();
734
- } else if (t.kind === "subst" || t.kind === "opt_subst") {
1042
+ } else if (t.kind === "subst") {
735
1043
  this.advance();
736
- node = { kind: "subst", path: t.value, optional: t.kind === "opt_subst", pos: { line: t.line, col: t.col } };
1044
+ if (!t.subst) {
1045
+ throw new ParseError(`internal: subst token missing payload`, t.line, t.col);
1046
+ }
1047
+ const payload = t.subst;
1048
+ node = { kind: "subst", segments: payload.segments, optional: payload.optional, listSuffix: payload.listSuffix, pos: { line: t.line, col: t.col } };
737
1049
  } else if (t.kind === "string" || t.kind === "triple_string") {
738
1050
  this.advance();
739
1051
  node = { kind: "scalar", raw: t.value, valueType: "string", pos: { line: t.line, col: t.col } };
@@ -784,6 +1096,20 @@ function parseTokens(tokens) {
784
1096
  return new Parser(tokens).parse();
785
1097
  }
786
1098
 
1099
+ // src/internal/parser/empty-check.ts
1100
+ function hasContentTokens(tokens) {
1101
+ return tokens.some((t) => t.kind !== "eof" && t.kind !== "newline");
1102
+ }
1103
+ function assertNonEmptyDocument(tokens, sourceDescriptor) {
1104
+ if (!hasContentTokens(tokens)) {
1105
+ throw new ParseError(
1106
+ `empty file is not a valid HOCON document (HOCON.md L130): ${sourceDescriptor}`,
1107
+ 1,
1108
+ 1
1109
+ );
1110
+ }
1111
+ }
1112
+
787
1113
  // src/internal/resolver/types.ts
788
1114
  var separatorValues = /* @__PURE__ */ new WeakSet();
789
1115
  function isSubst(v) {
@@ -803,64 +1129,20 @@ function makeResObj() {
803
1129
  }
804
1130
 
805
1131
  // src/internal/resolver/utils.ts
806
- function parseSubstPath(raw) {
807
- const segments = [];
808
- let i = 0;
809
- while (i < raw.length) {
810
- while (i < raw.length && (raw[i] === " " || raw[i] === " ")) i++;
811
- if (i >= raw.length) break;
812
- if (raw[i] === '"') {
813
- i++;
814
- let seg = "";
815
- while (i < raw.length && raw[i] !== '"') {
816
- if (raw[i] === "\\" && i + 1 < raw.length && (raw[i + 1] === '"' || raw[i + 1] === "\\")) {
817
- seg += raw[i + 1];
818
- i += 2;
819
- } else {
820
- seg += raw[i];
821
- i++;
822
- }
823
- }
824
- if (i < raw.length) i++;
825
- segments.push(seg);
826
- while (i < raw.length && (raw[i] === " " || raw[i] === " ")) i++;
827
- if (i < raw.length && raw[i] === ".") i++;
828
- } else if (raw[i] === ".") {
829
- segments.push("");
830
- i++;
831
- } else {
832
- let seg = "";
833
- while (i < raw.length && raw[i] !== ".") {
834
- seg += raw[i];
835
- i++;
836
- }
837
- segments.push(seg.trim());
838
- if (i < raw.length && raw[i] === ".") i++;
839
- }
840
- }
841
- return segments;
842
- }
843
1132
  function segmentsToKey(segments) {
844
1133
  return segments.map((s) => {
845
- if (s === "" || /[^a-zA-Z0-9\-_]/.test(s)) {
846
- return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1134
+ const t = s.text;
1135
+ if (t === "" || /[^a-zA-Z0-9\-_]/.test(t)) {
1136
+ return `"${t.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
847
1137
  }
848
- return s;
1138
+ return t;
849
1139
  }).join(".");
850
1140
  }
851
1141
  function lookupPath(root, segments) {
852
1142
  const [head, ...tail] = segments;
853
- if (head === void 0 || head === "") {
854
- if (segments.length > 0) {
855
- const val2 = root.fields.get("");
856
- if (val2 === void 0) return void 0;
857
- if (tail.length === 0) return val2;
858
- if (isResObj(val2)) return lookupPath(val2, tail);
859
- return void 0;
860
- }
861
- return void 0;
862
- }
863
- const val = root.fields.get(head);
1143
+ if (head === void 0) return void 0;
1144
+ const key = head.text;
1145
+ const val = root.fields.get(key);
864
1146
  if (val === void 0) return void 0;
865
1147
  if (tail.length === 0) return val;
866
1148
  if (isResObj(val)) return lookupPath(val, tail);
@@ -869,7 +1151,7 @@ function lookupPath(root, segments) {
869
1151
  function lookupResObj(root, segments) {
870
1152
  let cur = root;
871
1153
  for (const seg of segments) {
872
- const val = cur.fields.get(seg);
1154
+ const val = cur.fields.get(seg.text);
873
1155
  if (val === void 0 || !isResObj(val)) return void 0;
874
1156
  cur = val;
875
1157
  }
@@ -929,6 +1211,7 @@ import * as nodePath from "path";
929
1211
  // src/internal/properties/properties.ts
930
1212
  function parseProperties(input) {
931
1213
  const root = /* @__PURE__ */ Object.create(null);
1214
+ const pairs = [];
932
1215
  for (const line of input.split("\n")) {
933
1216
  const trimmed = line.trim();
934
1217
  if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("!")) continue;
@@ -937,6 +1220,10 @@ function parseProperties(input) {
937
1220
  const key = trimmed.slice(0, sepIdx).trim();
938
1221
  const value = trimmed.slice(sepIdx + 1).trim();
939
1222
  if (key === "") continue;
1223
+ pairs.push([key, value]);
1224
+ }
1225
+ pairs.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1226
+ for (const [key, value] of pairs) {
940
1227
  setNested(root, key.split("."), value);
941
1228
  }
942
1229
  return root;
@@ -960,6 +1247,7 @@ function setNested(obj, segments, value) {
960
1247
  }
961
1248
  const last = segments[segments.length - 1];
962
1249
  if (last === void 0 || DANGEROUS_KEYS.has(last)) return;
1250
+ if (typeof current[last] === "object" && current[last] !== null) return;
963
1251
  current[last] = value;
964
1252
  }
965
1253
  function propertiesToHoconValue(input) {
@@ -1073,7 +1361,9 @@ var IncludeLoader = class {
1073
1361
  if (candidate.endsWith(".properties")) {
1074
1362
  return hoconValueToResObj(propertiesToHoconValue(content));
1075
1363
  }
1076
- const ast = parseTokens(tokenize(content));
1364
+ const tokens = tokenize(content);
1365
+ assertNonEmptyDocument(tokens, candidate);
1366
+ const ast = parseTokens(tokens);
1077
1367
  return this.onBuildResObj(ast, {
1078
1368
  env,
1079
1369
  baseDir: nodePath.dirname(candidate),
@@ -1097,7 +1387,9 @@ var IncludeLoader = class {
1097
1387
  if (candidate.endsWith(".properties")) {
1098
1388
  return hoconValueToResObj(propertiesToHoconValue(content));
1099
1389
  }
1100
- const ast = parseTokens(tokenize(content));
1390
+ const tokens = tokenize(content);
1391
+ assertNonEmptyDocument(tokens, candidate);
1392
+ const ast = parseTokens(tokens);
1101
1393
  return this.onBuildResObjAsync(ast, {
1102
1394
  env,
1103
1395
  baseDir: nodePath.dirname(candidate),
@@ -1146,7 +1438,7 @@ var StructureBuilder = class _StructureBuilder {
1146
1438
  return;
1147
1439
  }
1148
1440
  const [head, ...tail] = field.key;
1149
- if (!head) return;
1441
+ if (head === void 0) return;
1150
1442
  if (tail.length > 0) {
1151
1443
  const syntheticAst = {
1152
1444
  kind: "object",
@@ -1188,7 +1480,7 @@ var StructureBuilder = class _StructureBuilder {
1188
1480
  return;
1189
1481
  }
1190
1482
  const [head, ...tail] = field.key;
1191
- if (!head) return;
1483
+ if (head === void 0) return;
1192
1484
  if (tail.length > 0) {
1193
1485
  const syntheticAst = {
1194
1486
  kind: "object",
@@ -1234,9 +1526,9 @@ var StructureBuilder = class _StructureBuilder {
1234
1526
  return inner;
1235
1527
  }
1236
1528
  case "subst":
1237
- return { _kind: "subst-placeholder", segments: parseSubstPath(ast.path), optional: ast.optional, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1529
+ return { _kind: "subst-placeholder", segments: ast.segments, optional: ast.optional, listSuffix: ast.listSuffix, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1238
1530
  case "concat":
1239
- return { _kind: "concat-placeholder", nodes: ast.nodes.map((n) => this.astToResolverValue(n, pathPrefix)) };
1531
+ return { _kind: "concat-placeholder", nodes: ast.nodes.map((n) => this.astToResolverValue(n, pathPrefix)), line: ast.pos.line, col: ast.pos.col };
1240
1532
  case "include":
1241
1533
  return { kind: "scalar", raw: "null", valueType: "null" };
1242
1534
  }
@@ -1258,13 +1550,13 @@ var StructureBuilder = class _StructureBuilder {
1258
1550
  case "object":
1259
1551
  return await this.buildAsync(ast, pathPrefix);
1260
1552
  case "subst":
1261
- return { _kind: "subst-placeholder", segments: parseSubstPath(ast.path), optional: ast.optional, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1553
+ return { _kind: "subst-placeholder", segments: ast.segments, optional: ast.optional, listSuffix: ast.listSuffix, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1262
1554
  case "concat": {
1263
1555
  const nodes = [];
1264
1556
  for (const n of ast.nodes) {
1265
1557
  nodes.push(await this.astToResolverValueAsync(n, pathPrefix));
1266
1558
  }
1267
- return { _kind: "concat-placeholder", nodes };
1559
+ return { _kind: "concat-placeholder", nodes, line: ast.pos.line, col: ast.pos.col };
1268
1560
  }
1269
1561
  case "include":
1270
1562
  return { kind: "scalar", raw: "null", valueType: "null" };
@@ -1273,7 +1565,8 @@ var StructureBuilder = class _StructureBuilder {
1273
1565
  // ---- Relativize substitution paths for nested includes ----
1274
1566
  relativizeSubstPaths(val, prefixSegments) {
1275
1567
  if (isSubst(val)) {
1276
- val.segments = [...prefixSegments, ...val.segments];
1568
+ const prefixAsSegments = prefixSegments.map((text) => ({ text, line: 0, col: 0 }));
1569
+ val.segments = [...prefixAsSegments, ...val.segments];
1277
1570
  val.prefixLen += prefixSegments.length;
1278
1571
  return;
1279
1572
  }
@@ -1316,6 +1609,19 @@ var SubstitutionResolver = class {
1316
1609
  this.opts = opts;
1317
1610
  this.resolving = /* @__PURE__ */ new Set();
1318
1611
  this.cache = /* @__PURE__ */ new Map();
1612
+ // Tracks ConcatPlaceholder nodes currently being iterated by resolveConcat.
1613
+ // Used by isSelfRef detection: fires only when found === a concat currently
1614
+ // mid-iteration (i.e. we are inside found's own resolution), preventing the
1615
+ // "foofoo" double-count without misfiring when an external field looks up the
1616
+ // same concat via a substitution (multi-reviewer convergence fix).
1617
+ //
1618
+ // Spec deviation: the S13a.13 spec ★1 decision #1 specified path-equality
1619
+ // preservation for self-ref detection. Round-2 multi-agent-review surfaced a
1620
+ // false-positive on external lookups (`a = ${?a}foo; b = ${a}`), so the
1621
+ // criterion was tightened to node-membership-in-iterating-set — strictly
1622
+ // narrower than path-equality. Spec amendment deferred to a follow-up
1623
+ // xx.hocon PR (see Phase 6 #3f close-out notes).
1624
+ this.resolvingConcats = /* @__PURE__ */ new WeakSet();
1319
1625
  }
1320
1626
  resolve() {
1321
1627
  return this.resolveResObj(this.root);
@@ -1354,7 +1660,14 @@ var SubstitutionResolver = class {
1354
1660
  }
1355
1661
  resolveVal(v, scope) {
1356
1662
  if (isSubst(v)) return this.resolveSubst(v, scope);
1357
- if (isConcat(v)) return this.resolveConcat(v.nodes, scope);
1663
+ if (isConcat(v)) {
1664
+ this.resolvingConcats.add(v);
1665
+ try {
1666
+ return this.resolveConcat(v.nodes, scope, v.line, v.col);
1667
+ } finally {
1668
+ this.resolvingConcats.delete(v);
1669
+ }
1670
+ }
1358
1671
  if (isAppend(v)) return this.resolveAppend(v, scope);
1359
1672
  if (isResObj(v)) return this.resolveResObj(v);
1360
1673
  const hv = v;
@@ -1368,23 +1681,20 @@ var SubstitutionResolver = class {
1368
1681
  }
1369
1682
  return hv;
1370
1683
  }
1371
- segmentsEqual(a, b) {
1372
- return a.length === b.length && a.every((seg, i) => seg === b[i]);
1373
- }
1374
1684
  resolveSubst(s, scope) {
1375
- const key = segmentsToKey(s.segments);
1685
+ const key = s.listSuffix ? `${segmentsToKey(s.segments)}[]` : segmentsToKey(s.segments);
1376
1686
  if (this.cache.has(key)) return this.cache.get(key);
1377
1687
  if (this.resolving.has(key)) {
1378
1688
  let prior;
1379
- if (s.prefixLen > 0) {
1380
- const leafSeg = s.segments[s.segments.length - 1] ?? "";
1689
+ if (s.segments.length > 1) {
1690
+ const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1381
1691
  const parentScope = lookupResObj(
1382
1692
  this.root,
1383
1693
  s.segments.slice(0, s.segments.length - 1)
1384
1694
  );
1385
1695
  prior = parentScope?.priorValues.get(leafSeg);
1386
1696
  } else {
1387
- const rootSeg = s.segments[0] ?? "";
1697
+ const rootSeg = s.segments[0]?.text ?? "";
1388
1698
  prior = scope.priorValues.get(rootSeg) ?? this.root.priorValues.get(rootSeg);
1389
1699
  }
1390
1700
  if (prior !== void 0) {
@@ -1408,34 +1718,36 @@ var SubstitutionResolver = class {
1408
1718
  try {
1409
1719
  const found = lookupPath(this.root, s.segments);
1410
1720
  if (found !== void 0) {
1411
- if (isSubst(found) || isConcat(found)) {
1412
- const isSelfRef = isSubst(found) ? this.segmentsEqual(found.segments, s.segments) : isConcat(found) && found.nodes.some(
1413
- (n) => isSubst(n) && this.segmentsEqual(n.segments, s.segments)
1414
- );
1415
- if (isSelfRef) {
1416
- let prior;
1417
- if (s.prefixLen > 0) {
1418
- const leafSeg = s.segments[s.segments.length - 1] ?? "";
1419
- const parentScope = lookupResObj(
1420
- this.root,
1421
- s.segments.slice(0, s.segments.length - 1)
1422
- );
1423
- prior = parentScope?.priorValues.get(leafSeg);
1424
- } else {
1425
- const rootSeg = s.segments[0] ?? "";
1426
- prior = scope.priorValues.get(rootSeg) ?? this.root.priorValues.get(rootSeg);
1427
- }
1428
- if (prior !== void 0) {
1429
- const result2 = this.resolveVal(prior, scope);
1430
- if (result2 !== void 0) this.cache.set(key, result2);
1431
- return result2;
1432
- }
1721
+ if (isConcat(found) && this.resolvingConcats.has(found)) {
1722
+ let prior;
1723
+ if (s.segments.length > 1) {
1724
+ const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1725
+ const parentScope = lookupResObj(
1726
+ this.root,
1727
+ s.segments.slice(0, s.segments.length - 1)
1728
+ );
1729
+ prior = parentScope?.priorValues.get(leafSeg);
1730
+ } else {
1731
+ const rootSeg = s.segments[0]?.text ?? "";
1732
+ prior = scope.priorValues.get(rootSeg) ?? this.root.priorValues.get(rootSeg);
1733
+ }
1734
+ if (prior !== void 0) {
1735
+ const result2 = this.resolveVal(prior, scope);
1736
+ if (result2 !== void 0) this.cache.set(key, result2);
1737
+ return result2;
1433
1738
  }
1739
+ if (s.optional) return void 0;
1740
+ throw new ResolveError(
1741
+ `could not resolve substitution: \${${key}}`,
1742
+ key,
1743
+ s.line,
1744
+ s.col
1745
+ );
1434
1746
  }
1435
1747
  let result = this.resolveVal(found, scope);
1436
1748
  const effectiveLen = s.segments.length - s.prefixLen;
1437
1749
  if (effectiveLen === 1 && result !== void 0 && result.kind === "object") {
1438
- const leafSeg = s.segments[s.segments.length - 1] ?? "";
1750
+ const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1439
1751
  let prior;
1440
1752
  if (s.prefixLen > 0) {
1441
1753
  const parentScope = lookupResObj(
@@ -1459,8 +1771,23 @@ var SubstitutionResolver = class {
1459
1771
  if (result !== void 0) this.cache.set(key, result);
1460
1772
  return result;
1461
1773
  }
1462
- const envKey = s.segments.join(".");
1463
- const envVal = this.opts.env[envKey] ?? (s.prefixLen > 0 ? this.opts.env[s.segments.slice(s.prefixLen).join(".")] : void 0);
1774
+ if (s.listSuffix) {
1775
+ const result = this.resolveEnvList(s);
1776
+ if (result !== void 0) {
1777
+ this.cache.set(key, result);
1778
+ return result;
1779
+ }
1780
+ if (s.optional) return void 0;
1781
+ const envBase = s.segments.map((seg) => seg.text).join(".");
1782
+ throw new ResolveError(
1783
+ `could not resolve substitution: \${${key}} (no environment variables ${envBase}_0, ${envBase}_1, \u2026 set)`,
1784
+ key,
1785
+ s.line,
1786
+ s.col
1787
+ );
1788
+ }
1789
+ const envKey = s.segments.map((seg) => seg.text).join(".");
1790
+ const envVal = this.opts.env[envKey] ?? (s.prefixLen > 0 ? this.opts.env[s.segments.slice(s.prefixLen).map((seg) => seg.text).join(".")] : void 0);
1464
1791
  if (envVal !== void 0) {
1465
1792
  const result = { kind: "scalar", raw: envVal, valueType: "string" };
1466
1793
  this.cache.set(key, result);
@@ -1477,37 +1804,106 @@ var SubstitutionResolver = class {
1477
1804
  this.resolving.delete(key);
1478
1805
  }
1479
1806
  }
1480
- resolveConcat(nodes, scope) {
1807
+ /**
1808
+ * S13c: scan environment for NAME_0, NAME_1, … and return an Array<Scalar(String)>.
1809
+ *
1810
+ * Candidate bases follow the same fully-qualified→bare order used by the scalar
1811
+ * env fallback. The first base whose _0 key is present wins entirely — no
1812
+ * cross-base merging (spec §Relativized fallback precedence rules).
1813
+ *
1814
+ * Returns the HoconValue Array if any candidate base has _0, or undefined if
1815
+ * none do (caller applies optional/required handling).
1816
+ */
1817
+ resolveEnvList(s) {
1818
+ const bases = this.candidateBases(s);
1819
+ for (const base of bases) {
1820
+ const key0 = base + "_0";
1821
+ if (!(key0 in this.opts.env)) continue;
1822
+ const items = [];
1823
+ for (let i = 0; ; i++) {
1824
+ const k = base + "_" + i;
1825
+ if (!(k in this.opts.env)) break;
1826
+ items.push({ kind: "scalar", raw: this.opts.env[k], valueType: "string" });
1827
+ }
1828
+ return { kind: "array", items };
1829
+ }
1830
+ return void 0;
1831
+ }
1832
+ /**
1833
+ * Return the candidate base names for env-var-list lookup, in precedence order.
1834
+ * Mirrors the fully-qualified→bare fallback used by the scalar env path.
1835
+ */
1836
+ candidateBases(s) {
1837
+ const full = s.segments.map((seg) => seg.text).join(".");
1838
+ if (s.prefixLen === 0) return [full];
1839
+ const bare = s.segments.slice(s.prefixLen).map((seg) => seg.text).join(".");
1840
+ return [full, bare];
1841
+ }
1842
+ resolveConcat(nodes, scope, line = 0, col = 0) {
1481
1843
  const resolved = nodes.map((n) => this.resolveVal(n, scope)).filter((v) => v !== void 0);
1482
1844
  if (resolved.length === 0) return { kind: "scalar", raw: "null", valueType: "null" };
1483
1845
  if (resolved.length === 1) return resolved[0];
1484
1846
  const nonSep = resolved.filter((v) => !separatorValues.has(v));
1485
- if (nonSep.length > 0 && nonSep.every((v) => v.kind === "object")) {
1486
- const merged = /* @__PURE__ */ new Map();
1487
- for (const v of nonSep) {
1488
- if (v.kind === "object") {
1489
- for (const [k, val] of v.fields) {
1490
- const existing = merged.get(k);
1491
- if (existing?.kind === "object" && val.kind === "object") {
1492
- merged.set(k, deepMergeHoconValues(existing, val));
1493
- } else {
1494
- merged.set(k, val);
1495
- }
1496
- }
1847
+ if (nonSep.length === 0) {
1848
+ const str = resolved.map((v) => v.kind === "scalar" ? v.raw : JSON.stringify(v)).join("");
1849
+ return { kind: "scalar", raw: str, valueType: "string" };
1850
+ }
1851
+ const joinPair = (left, right) => {
1852
+ if (left.kind === "object" && right.kind === "object") {
1853
+ return deepMergeHoconValues(left, right);
1854
+ }
1855
+ if (left.kind === "array" && right.kind === "object") {
1856
+ const converted = numericObjectToArray(right);
1857
+ if (converted !== null) {
1858
+ return { kind: "array", items: [...left.items, ...converted] };
1497
1859
  }
1860
+ throw new ResolveError(
1861
+ "cannot concatenate array with object: value concatenation requires same-kind operands (S10.4)",
1862
+ "",
1863
+ line,
1864
+ col
1865
+ );
1498
1866
  }
1499
- return { kind: "object", fields: merged };
1500
- }
1501
- if (resolved.some((v) => v.kind === "array")) {
1502
- const items = [];
1503
- for (const v of resolved) {
1504
- if (v.kind === "array") items.push(...v.items);
1505
- else items.push(v);
1867
+ if (left.kind === "object" && right.kind === "array") {
1868
+ const converted = numericObjectToArray(left);
1869
+ if (converted !== null) {
1870
+ return { kind: "array", items: [...converted, ...right.items] };
1871
+ }
1872
+ throw new ResolveError(
1873
+ "cannot concatenate object with array: value concatenation requires same-kind operands (S10.4)",
1874
+ "",
1875
+ line,
1876
+ col
1877
+ );
1506
1878
  }
1507
- return { kind: "array", items };
1879
+ if (left.kind === "array" && right.kind === "array") {
1880
+ return { kind: "array", items: [...left.items, ...right.items] };
1881
+ }
1882
+ if (left.kind === "array" || right.kind === "array") {
1883
+ throw new ResolveError(
1884
+ `cannot concatenate ${left.kind} with ${right.kind}: arrays and objects may not appear in string value concatenation (S10.13)`,
1885
+ "",
1886
+ line,
1887
+ col
1888
+ );
1889
+ }
1890
+ if (left.kind === "object" || right.kind === "object") {
1891
+ throw new ResolveError(
1892
+ `cannot concatenate ${left.kind} with ${right.kind}: arrays and objects may not appear in string value concatenation (S10.13)`,
1893
+ "",
1894
+ line,
1895
+ col
1896
+ );
1897
+ }
1898
+ return { kind: "scalar", raw: left.raw + right.raw, valueType: "string" };
1899
+ };
1900
+ const [head, ...tail] = nonSep;
1901
+ const folded = tail.reduce(joinPair, head);
1902
+ if (folded.kind === "scalar") {
1903
+ const str = resolved.map((v) => v.kind === "scalar" ? v.raw : JSON.stringify(v)).join("");
1904
+ return { kind: "scalar", raw: str, valueType: "string" };
1508
1905
  }
1509
- const str = resolved.map((v) => v.kind === "scalar" ? v.raw : JSON.stringify(v)).join("");
1510
- return { kind: "scalar", raw: str, valueType: "string" };
1906
+ return folded;
1511
1907
  }
1512
1908
  resolveAppend(a, scope) {
1513
1909
  const existing = this.resolveVal(a.existing, scope) ?? { kind: "array", items: [] };
@@ -1544,6 +1940,7 @@ async function defaultReadFile(filePath) {
1544
1940
  }
1545
1941
  function buildResolveContext(input, opts) {
1546
1942
  const tokens = tokenize(input);
1943
+ assertNonEmptyDocument(tokens, "input");
1547
1944
  const ast = parseTokens(tokens);
1548
1945
  const resolveOpts = {
1549
1946
  env: getEnv(opts),