@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.cjs CHANGED
@@ -43,6 +43,27 @@ module.exports = __toCommonJS(src_exports);
43
43
 
44
44
  // src/coerce.ts
45
45
  var DECIMAL_NUMBER_RE = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
46
+ function isHoconWs(cp) {
47
+ if (cp === 9 || cp === 10 || cp === 11 || cp === 12 || cp === 13) return true;
48
+ if (cp >= 28 && cp <= 31) return true;
49
+ if (cp === 32 || cp === 160 || cp === 65279) return true;
50
+ if (cp === 5760) return true;
51
+ if (cp >= 8192 && cp <= 8202) return true;
52
+ if (cp === 8232 || cp === 8233 || cp === 8239 || cp === 8287) return true;
53
+ if (cp === 12288) return true;
54
+ return false;
55
+ }
56
+ function trimHoconWs(s) {
57
+ let start = 0;
58
+ let end = s.length;
59
+ while (start < end && isHoconWs(s.charCodeAt(start))) {
60
+ start++;
61
+ }
62
+ while (end > start && isHoconWs(s.charCodeAt(end - 1))) {
63
+ end--;
64
+ }
65
+ return s.slice(start, end);
66
+ }
46
67
  var TRUTHY = /* @__PURE__ */ new Set(["true", "yes", "on"]);
47
68
  var FALSY = /* @__PURE__ */ new Set(["false", "no", "off"]);
48
69
  function coerceBoolean(value) {
@@ -88,22 +109,29 @@ var OUTPUT_DURATION_UNITS = {
88
109
  d: 864e5
89
110
  };
90
111
  function parseDuration(value, outputUnit = "ms") {
91
- const trimmed = value.trim();
112
+ const trimmed = trimHoconWs(value);
92
113
  let i = 0;
93
114
  while (i < trimmed.length) {
94
115
  const ch = trimmed[i];
95
- if (ch !== "-" && ch !== "." && (ch < "0" || ch > "9")) break;
116
+ if (i === 0 && (ch === "-" || ch === "+")) {
117
+ i++;
118
+ continue;
119
+ }
120
+ if (ch !== "." && (ch < "0" || ch > "9")) break;
96
121
  i++;
97
122
  }
98
123
  if (i === 0) return NaN;
99
124
  const num = Number(trimmed.slice(0, i));
100
125
  if (Number.isNaN(num)) return NaN;
101
- const unit = trimmed.slice(i).trim().toLowerCase();
126
+ const unit = trimHoconWs(trimmed.slice(i)).toLowerCase();
127
+ const divisor = OUTPUT_DURATION_UNITS[outputUnit];
128
+ if (divisor === void 0) return NaN;
129
+ if (unit === "") {
130
+ return num / divisor;
131
+ }
102
132
  const mult = DURATION_UNITS[unit];
103
133
  if (mult === void 0) return NaN;
104
134
  const ms = num * mult;
105
- const divisor = OUTPUT_DURATION_UNITS[outputUnit];
106
- if (divisor === void 0) return NaN;
107
135
  return ms / divisor;
108
136
  }
109
137
  var BYTE_UNITS = {
@@ -134,6 +162,22 @@ var BYTE_UNITS = {
134
162
  TiB: 1099511627776,
135
163
  tebibyte: 1099511627776,
136
164
  tebibytes: 1099511627776,
165
+ // S21.4 — HOCON.md L1385: single-letter abbreviations → powers of two (java -Xmx convention).
166
+ // Lightbend typesafe-config 1.4.3 verified: 1K=1024, 1M=1048576, etc.
167
+ // Both upper- and lowercase accepted per Lightbend behaviour.
168
+ // Z/Y (2^70/2^80) are deferred — they overflow MAX_SAFE_INTEGER and require BigInt accessor.
169
+ K: 1024,
170
+ k: 1024,
171
+ M: 1024 ** 2,
172
+ m: 1024 ** 2,
173
+ G: 1024 ** 3,
174
+ g: 1024 ** 3,
175
+ T: 1024 ** 4,
176
+ t: 1024 ** 4,
177
+ P: 1024 ** 5,
178
+ p: 1024 ** 5,
179
+ E: 1024 ** 6,
180
+ e: 1024 ** 6,
137
181
  // lowercase short-form aliases
138
182
  b: 1,
139
183
  kb: 1e3,
@@ -157,25 +201,39 @@ var OUTPUT_BYTE_UNITS = {
157
201
  TiB: 1099511627776
158
202
  };
159
203
  function parseBytes(value, outputUnit = "B") {
160
- const trimmed = value.trim();
204
+ const trimmed = trimHoconWs(value);
161
205
  let i = 0;
162
206
  while (i < trimmed.length) {
163
207
  const ch = trimmed[i];
164
- if (ch !== "-" && ch !== "." && (ch < "0" || ch > "9")) break;
208
+ if (i === 0 && (ch === "-" || ch === "+")) {
209
+ i++;
210
+ continue;
211
+ }
212
+ if (ch !== "." && (ch < "0" || ch > "9")) break;
165
213
  i++;
166
214
  }
167
215
  if (i === 0) return NaN;
168
216
  const num = Number(trimmed.slice(0, i));
169
217
  if (Number.isNaN(num)) return NaN;
170
- const unit = trimmed.slice(i).trim();
218
+ const unit = trimHoconWs(trimmed.slice(i));
219
+ const divisor = OUTPUT_BYTE_UNITS[outputUnit];
220
+ if (divisor === void 0) return NaN;
221
+ if (unit === "") {
222
+ const bytes2 = Math.trunc(num);
223
+ if (Math.abs(bytes2) > Number.MAX_SAFE_INTEGER) {
224
+ throw new RangeError("byte size overflows representable range (max 2^53-1 bytes)");
225
+ }
226
+ return bytes2 / divisor;
227
+ }
171
228
  let mult = BYTE_UNITS[unit];
172
229
  if (mult === void 0) {
173
230
  mult = BYTE_UNITS[unit.toLowerCase()];
174
231
  }
175
232
  if (mult === void 0) return NaN;
176
233
  const bytes = num * mult;
177
- const divisor = OUTPUT_BYTE_UNITS[outputUnit];
178
- if (divisor === void 0) return NaN;
234
+ if (Math.abs(bytes) > Number.MAX_SAFE_INTEGER) {
235
+ throw new RangeError("byte size overflows representable range (max 2^53-1 bytes)");
236
+ }
179
237
  const result = bytes / divisor;
180
238
  return outputUnit === "B" ? Math.round(result) : result;
181
239
  }
@@ -208,6 +266,23 @@ var ConfigError = class extends Error {
208
266
  }
209
267
  };
210
268
 
269
+ // src/value/numeric-array.ts
270
+ function numericObjectToArray(value) {
271
+ if (value.kind !== "object") return null;
272
+ if (value.fields.size === 0) return null;
273
+ const canonicalInt = /^(0|[1-9][0-9]*)$/;
274
+ const eligible = [];
275
+ for (const [key, val] of value.fields) {
276
+ if (!canonicalInt.test(key)) continue;
277
+ const n = Number(key);
278
+ if (!Number.isInteger(n) || n > 2147483647) continue;
279
+ eligible.push({ n, v: val });
280
+ }
281
+ if (eligible.length === 0) return null;
282
+ eligible.sort((a, b) => a.n - b.n);
283
+ return eligible.map((e) => e.v);
284
+ }
285
+
211
286
  // src/config.ts
212
287
  var Config = class _Config {
213
288
  constructor(root) {
@@ -250,6 +325,7 @@ var Config = class _Config {
250
325
  }
251
326
  const result = parseBytes(v.raw, unit);
252
327
  if (Number.isNaN(result)) throw new ConfigError(`invalid byte size at ${path2}: ${JSON.stringify(v.raw)}`, path2);
328
+ if (result < 0) throw new ConfigError(`byte size must be non-negative at ${path2}: ${JSON.stringify(v.raw)}`, path2);
253
329
  return result;
254
330
  }
255
331
  getConfig(path2) {
@@ -261,6 +337,10 @@ var Config = class _Config {
261
337
  getList(path2) {
262
338
  const v = this.lookupNode(path2);
263
339
  if (v === void 0) throw new ConfigError(`path not found: ${path2}`, path2);
340
+ if (v.kind === "object") {
341
+ const converted = numericObjectToArray(v);
342
+ if (converted !== null) return converted.map(hoconToJs);
343
+ }
264
344
  if (v.kind !== "array") throw new ConfigError(`expected array at ${path2}`, path2);
265
345
  return v.items.map(hoconToJs);
266
346
  }
@@ -373,6 +453,20 @@ function deepMergeHocon(receiver, fallback) {
373
453
  }
374
454
 
375
455
  // src/internal/lexer/lexer.ts
456
+ function isHoconWhitespace(ch) {
457
+ const cp = ch.codePointAt(0) ?? -1;
458
+ if (cp === 9 || cp === 10 || cp === 11 || cp === 12 || cp === 13) return true;
459
+ if (cp >= 28 && cp <= 31) return true;
460
+ if (cp === 32 || cp === 160 || cp === 65279) return true;
461
+ if (cp === 5760) return true;
462
+ if (cp >= 8192 && cp <= 8202) return true;
463
+ if (cp === 8232 || cp === 8233 || cp === 8239 || cp === 8287) return true;
464
+ if (cp === 12288) return true;
465
+ return false;
466
+ }
467
+ function isHoconNewline(ch) {
468
+ return ch === "\n";
469
+ }
376
470
  var SINGLE_CHAR_TOKENS = {
377
471
  "{": "lbrace",
378
472
  "}": "rbrace",
@@ -395,18 +489,18 @@ var Lexer = class {
395
489
  while (this.pos < this.input.length) {
396
490
  const sl = this.line, sc = this.col;
397
491
  const ch = this.peek();
398
- if (ch === " " || ch === " " || ch === "\r") {
399
- this.advance();
400
- this.hadSpace = true;
401
- continue;
402
- }
403
- if (ch === "\n") {
492
+ if (isHoconNewline(ch)) {
404
493
  this.advance();
405
494
  if (this.tokens[this.tokens.length - 1]?.kind !== "newline") {
406
495
  this.push("newline", "\n", sl, sc);
407
496
  }
408
497
  continue;
409
498
  }
499
+ if (isHoconWhitespace(ch)) {
500
+ this.advance();
501
+ this.hadSpace = true;
502
+ continue;
503
+ }
410
504
  if (ch === "/" && this.peek(1) === "/") {
411
505
  while (this.pos < this.input.length && this.peek() !== "\n") this.advance();
412
506
  this.hadSpace = true;
@@ -436,16 +530,16 @@ var Lexer = class {
436
530
  if (ch === "$" && this.peek(1) === "{") {
437
531
  this.advance();
438
532
  this.advance();
439
- const optional = this.peek() === "?";
440
- if (optional) this.advance();
441
- let path2 = "";
442
- while (this.pos < this.input.length && this.peek() !== "}") {
443
- if (this.peek() === "\n") throw new ParseError("unterminated substitution", sl, sc);
444
- path2 += this.advance();
445
- }
446
- if (this.peek() !== "}") throw new ParseError("unterminated substitution", sl, sc);
447
- this.advance();
448
- this.push(optional ? "opt_subst" : "subst", path2.trim(), sl, sc);
533
+ const payload = this.parseSubstBody(sl, sc);
534
+ const value = payload.segments.map((s) => {
535
+ const t = s.text;
536
+ if (t === "" || t.includes(".") || t.includes(" ") || t.includes(" ") || t.includes('"') || t.includes("\\") || t !== t.trim()) {
537
+ const escaped = t.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
538
+ return `"${escaped}"`;
539
+ }
540
+ return t;
541
+ }).join(".");
542
+ this.pushSubst(payload, value, sl, sc);
449
543
  continue;
450
544
  }
451
545
  if (ch === '"' && this.peek(1) === '"' && this.peek(2) === '"') {
@@ -480,58 +574,7 @@ var Lexer = class {
480
574
  }
481
575
  if (ch === '"') {
482
576
  this.advance();
483
- let value = "";
484
- while (this.pos < this.input.length && this.peek() !== '"') {
485
- if (this.peek() === "\n") throw new ParseError("unterminated string", sl, sc);
486
- if (this.peek() === "\\") {
487
- this.advance();
488
- const esc = this.advance();
489
- switch (esc) {
490
- case "n":
491
- value += "\n";
492
- break;
493
- case "t":
494
- value += " ";
495
- break;
496
- case "r":
497
- value += "\r";
498
- break;
499
- case '"':
500
- value += '"';
501
- break;
502
- case "\\":
503
- value += "\\";
504
- break;
505
- case "/":
506
- value += "/";
507
- break;
508
- case "b":
509
- value += "\b";
510
- break;
511
- case "f":
512
- value += "\f";
513
- break;
514
- case "u": {
515
- if (this.pos + 4 > this.input.length) {
516
- throw new ParseError("Invalid unicode escape: not enough characters", this.line, this.col);
517
- }
518
- const hex = this.input.slice(this.pos, this.pos + 4);
519
- if (!/^[0-9a-fA-F]{4}$/.test(hex)) {
520
- throw new ParseError(`Invalid unicode escape: \\u${hex}`, this.line, this.col);
521
- }
522
- value += String.fromCharCode(parseInt(hex, 16));
523
- for (let i = 0; i < 4; i++) this.advance();
524
- break;
525
- }
526
- default:
527
- throw new ParseError(`Unknown escape sequence: \\${esc}`, this.line, this.col);
528
- }
529
- } else {
530
- value += this.advance();
531
- }
532
- }
533
- if (this.peek() !== '"') throw new ParseError("unterminated string", sl, sc);
534
- this.advance();
577
+ const value = this.readQuotedStringBody(sl, sc);
535
578
  this.push("string", value, sl, sc, true);
536
579
  continue;
537
580
  }
@@ -565,22 +608,255 @@ var Lexer = class {
565
608
  this.tokens.push({ kind, value, line: l, col: c, isQuoted, precedingSpace: this.hadSpace });
566
609
  this.hadSpace = false;
567
610
  }
611
+ pushSubst(payload, value, l, c) {
612
+ this.tokens.push({ kind: "subst", value, line: l, col: c, isQuoted: false, precedingSpace: this.hadSpace, subst: payload });
613
+ this.hadSpace = false;
614
+ }
615
+ /**
616
+ * Read body of a quoted string. Opening `"` must already be consumed.
617
+ * Returns decoded string. Throws ParseError on unterminated/invalid-escape.
618
+ * openLine/openCol are the position of the opening `"` for error reporting.
619
+ */
620
+ readQuotedStringBody(openLine, openCol) {
621
+ let value = "";
622
+ while (this.pos < this.input.length && this.peek() !== '"') {
623
+ if (this.peek() === "\n") throw new ParseError("unterminated string", openLine, openCol);
624
+ if (this.peek() === "\\") {
625
+ const escCol = this.col;
626
+ this.advance();
627
+ if (this.pos >= this.input.length) {
628
+ throw new ParseError("unterminated string", openLine, openCol);
629
+ }
630
+ const esc = this.advance();
631
+ switch (esc) {
632
+ case "n":
633
+ value += "\n";
634
+ break;
635
+ case "t":
636
+ value += " ";
637
+ break;
638
+ case "r":
639
+ value += "\r";
640
+ break;
641
+ case '"':
642
+ value += '"';
643
+ break;
644
+ case "\\":
645
+ value += "\\";
646
+ break;
647
+ case "/":
648
+ value += "/";
649
+ break;
650
+ case "b":
651
+ value += "\b";
652
+ break;
653
+ case "f":
654
+ value += "\f";
655
+ break;
656
+ case "u": {
657
+ if (this.pos + 4 > this.input.length) {
658
+ throw new ParseError("invalid unicode escape", openLine, escCol);
659
+ }
660
+ const hex = this.input.slice(this.pos, this.pos + 4);
661
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) {
662
+ throw new ParseError("invalid unicode escape", openLine, escCol);
663
+ }
664
+ const code = parseInt(hex, 16);
665
+ value += String.fromCharCode(code);
666
+ for (let i = 0; i < 4; i++) this.advance();
667
+ break;
668
+ }
669
+ default:
670
+ throw new ParseError(`unknown escape sequence: \\${esc}`, openLine, escCol);
671
+ }
672
+ } else {
673
+ value += this.advance();
674
+ }
675
+ }
676
+ if (this.pos >= this.input.length || this.peek() !== '"') {
677
+ throw new ParseError("unterminated string", openLine, openCol);
678
+ }
679
+ this.advance();
680
+ return value;
681
+ }
682
+ /**
683
+ * Parse the body of a `${...}` substitution (called after `${` has been consumed).
684
+ * Implements Appendix A state machine from the cross-language spec.
685
+ *
686
+ * S13c extension: if the segment-collection loop encounters `[`, it delegates to
687
+ * `parseListSuffix()` which consumes the literal `[]` and sets `listSuffix=true`.
688
+ * ASCII space/tab before `[` is buffered in `pendingWs` and discarded (E7).
689
+ */
690
+ parseSubstBody(startLine, startCol) {
691
+ let optional = false;
692
+ if (this.peek() === "?") {
693
+ this.advance();
694
+ optional = true;
695
+ }
696
+ let curText = "";
697
+ let curStarted = false;
698
+ let curLine = 0;
699
+ let curCol = 0;
700
+ let pendingWs = "";
701
+ const segments = [];
702
+ let lastDot = null;
703
+ let listSuffix = false;
704
+ while (true) {
705
+ if (this.pos >= this.input.length) {
706
+ throw new ParseError("unterminated substitution", startLine, startCol);
707
+ }
708
+ const ch = this.peek();
709
+ if (ch === "}") {
710
+ this.advance();
711
+ pendingWs = "";
712
+ break;
713
+ } else if (ch === "[") {
714
+ if (!curStarted) {
715
+ throw new ParseError("empty segment before '[]' suffix in substitution path", startLine, this.col);
716
+ }
717
+ for (const w of pendingWs) {
718
+ if (w !== " " && w !== " ") {
719
+ throw new ParseError(
720
+ `only ASCII space or tab allowed between substitution path and '[]' suffix (got ${JSON.stringify(w)}, HOCON extra-spec E7)`,
721
+ startLine,
722
+ this.col
723
+ );
724
+ }
725
+ }
726
+ segments.push({ text: curText, line: curLine, col: curCol });
727
+ pendingWs = "";
728
+ listSuffix = this.parseListSuffix(startLine);
729
+ break;
730
+ } else if (ch === '"') {
731
+ const qLine = startLine;
732
+ const qCol = this.col;
733
+ if (curStarted) {
734
+ curText += pendingWs;
735
+ }
736
+ pendingWs = "";
737
+ this.advance();
738
+ const decoded = this.readQuotedStringBody(qLine, qCol);
739
+ curText += decoded;
740
+ if (!curStarted) {
741
+ curLine = qLine;
742
+ curCol = qCol;
743
+ curStarted = true;
744
+ }
745
+ } else if (isUnquotedSubstChar(ch)) {
746
+ if (ch === "-" && !curStarted && !isDecimalDigit(this.peek(1))) {
747
+ const after = this.peek(1) === "" ? "EOF" : JSON.stringify(this.peek(1));
748
+ throw new ParseError(
749
+ `unquoted path segment cannot begin with '-' unless followed by a digit (got '-' then ${after}, HOCON.md L270-276)`,
750
+ startLine,
751
+ this.col
752
+ );
753
+ }
754
+ const uCol = this.col;
755
+ if (curStarted) {
756
+ curText += pendingWs;
757
+ }
758
+ pendingWs = "";
759
+ if (!curStarted) {
760
+ curLine = startLine;
761
+ curCol = uCol;
762
+ curStarted = true;
763
+ }
764
+ while (this.pos < this.input.length && isUnquotedSubstChar(this.peek())) {
765
+ curText += this.advance();
766
+ }
767
+ } else if (ch === ".") {
768
+ const dotCol = this.col;
769
+ pendingWs = "";
770
+ if (!curStarted) {
771
+ throw new ParseError("empty segment in path", startLine, dotCol);
772
+ }
773
+ segments.push({ text: curText, line: curLine, col: curCol });
774
+ curText = "";
775
+ curStarted = false;
776
+ curLine = 0;
777
+ curCol = 0;
778
+ lastDot = [startLine, dotCol];
779
+ this.advance();
780
+ } else if (isHoconNewline(ch)) {
781
+ throw new ParseError("unterminated substitution", startLine, startCol);
782
+ } else if (isHoconWhitespace(ch)) {
783
+ pendingWs += ch;
784
+ this.advance();
785
+ } else {
786
+ throw new ParseError(`unexpected character in substitution path: ${JSON.stringify(ch)}`, startLine, this.col);
787
+ }
788
+ }
789
+ if (!listSuffix) {
790
+ if (curStarted) {
791
+ segments.push({ text: curText, line: curLine, col: curCol });
792
+ } else if (segments.length === 0) {
793
+ throw new ParseError("empty substitution path", startLine, startCol);
794
+ } else {
795
+ const [errLine, errCol] = lastDot ?? [startLine, startCol];
796
+ throw new ParseError("empty segment in path", errLine, errCol);
797
+ }
798
+ }
799
+ return { segments, optional, listSuffix };
800
+ }
801
+ /**
802
+ * Consume the literal `[]` suffix inside a substitution body.
803
+ * Called after the `[` has been detected (but not yet consumed).
804
+ * Requires the next character to be `[`, then exactly `]`, then `}`.
805
+ * Whitespace inside `[]` is not permitted (per spec Decision §1).
806
+ * Returns true (always — only called when listSuffix path is taken).
807
+ */
808
+ parseListSuffix(startLine) {
809
+ this.advance();
810
+ const afterBracket = this.peek();
811
+ if (afterBracket !== "]") {
812
+ const desc = afterBracket === "" ? "EOF" : JSON.stringify(afterBracket);
813
+ throw new ParseError(
814
+ `expected ']' after '[' in substitution list suffix (got ${desc})`,
815
+ startLine,
816
+ this.col
817
+ );
818
+ }
819
+ this.advance();
820
+ if (this.peek() !== "}") {
821
+ const desc = this.peek() === "" ? "EOF" : JSON.stringify(this.peek());
822
+ throw new ParseError(
823
+ `expected '}' after '[]' in substitution list suffix (got ${desc})`,
824
+ startLine,
825
+ this.col
826
+ );
827
+ }
828
+ this.advance();
829
+ return true;
830
+ }
568
831
  };
569
832
  function tokenize(input) {
570
833
  return new Lexer(input).tokenize();
571
834
  }
835
+ function isUnquotedSubstChar(ch) {
836
+ if (ch === "" || isHoconWhitespace(ch)) return false;
837
+ if ('"\\'.includes(ch)) return false;
838
+ if ("{}[]".includes(ch)) return false;
839
+ if (":=,+#`^?!@*&$.".includes(ch)) return false;
840
+ return true;
841
+ }
842
+ function isDecimalDigit(ch) {
843
+ return ch >= "0" && ch <= "9";
844
+ }
572
845
  function isUnquotedStart(ch) {
573
- return ch !== "" && !'{}[],:=+#\n\r "$?!@*&^\\'.includes(ch);
846
+ if (ch === "" || isHoconWhitespace(ch)) return false;
847
+ if ('{}[],:=+#"$?!@*&^\\'.includes(ch)) return false;
848
+ return true;
574
849
  }
575
850
  function isUnquotedContinue(ch, nextFn) {
576
- if (ch === "" || '{}[],:=\n\r #"$?!@*&^\\'.includes(ch) || ch === " ") return false;
851
+ if (ch === "" || isHoconWhitespace(ch)) return false;
852
+ if ('{}[],:=#"$?!@*&^\\'.includes(ch)) return false;
577
853
  if (ch === "+" && nextFn() === "=") return false;
578
854
  if (ch === "/" && nextFn() === "/") return false;
579
855
  return true;
580
856
  }
581
857
 
582
858
  // src/internal/parser/parser.ts
583
- var EOF_TOKEN = { kind: "eof", value: "", line: 0, col: 0, isQuoted: false, precedingSpace: false };
859
+ var EOF_TOKEN = { kind: "eof", value: "", line: 0, col: 0, isQuoted: false, precedingSpace: false, subst: void 0 };
584
860
  var Parser = class {
585
861
  constructor(tokens) {
586
862
  this.tokens = tokens;
@@ -638,6 +914,14 @@ var Parser = class {
638
914
  const t = this.peek();
639
915
  if (t.kind === "eof" || t.kind === "rbrace") break;
640
916
  if (t.kind === "unquoted" && t.value === "include") {
917
+ const next = this.peek(1);
918
+ if (next.kind === "equals" || next.kind === "colon" || next.kind === "plus_equals" || next.kind === "lbrace") {
919
+ throw new ParseError(
920
+ `'include' is reserved at the start of a key path expression; use "include" (quoted) to use it as a key`,
921
+ t.line,
922
+ t.col
923
+ );
924
+ }
641
925
  this.advance();
642
926
  fields.push(this.parseInclude());
643
927
  this.skip("newline");
@@ -646,7 +930,14 @@ var Parser = class {
646
930
  continue;
647
931
  }
648
932
  const keyPos = this.currentPos();
649
- const key = this.parseKey();
933
+ const { segments: key, firstWasQuoted } = this.parseKey();
934
+ if (key[0] === "include" && !firstWasQuoted) {
935
+ throw new ParseError(
936
+ `'include' is reserved at the start of a key path expression; use "include".foo (quoted) or rename the key`,
937
+ keyPos.line,
938
+ keyPos.col
939
+ );
940
+ }
650
941
  this.skip("newline");
651
942
  let append = false;
652
943
  const sep = this.peek();
@@ -676,18 +967,31 @@ var Parser = class {
676
967
  parseKey() {
677
968
  const segments = [];
678
969
  let trailingDot = false;
970
+ let firstWasQuoted = false;
679
971
  while (true) {
680
972
  const t = this.peek();
681
973
  if (t.kind === "string") {
682
974
  this.advance();
975
+ if (segments.length === 0) firstWasQuoted = true;
683
976
  segments.push(t.value);
684
977
  trailingDot = false;
685
978
  } else if (t.kind === "unquoted") {
686
979
  this.advance();
687
- const parts = t.value.split(".");
980
+ const raw = t.value.startsWith(".") && segments.length > 0 && !t.precedingSpace ? t.value.slice(1) : t.value;
981
+ const parts = raw.split(".");
688
982
  const filtered = parts.filter((s) => s.length > 0);
983
+ for (const seg of filtered) {
984
+ if (seg[0] === "-" && !(seg.length >= 2 && seg[1] >= "0" && seg[1] <= "9")) {
985
+ const after = seg.length >= 2 ? JSON.stringify(seg[1]) : "EOF";
986
+ throw new ParseError(
987
+ `unquoted key segment cannot begin with '-' unless followed by a digit (got '-' then ${after} in ${JSON.stringify(seg)}, HOCON.md L270-276)`,
988
+ t.line,
989
+ t.col
990
+ );
991
+ }
992
+ }
689
993
  segments.push(...filtered);
690
- trailingDot = t.value.endsWith(".");
994
+ trailingDot = raw.endsWith(".");
691
995
  } else {
692
996
  if (segments.length === 0) throw new ParseError(`expected key, got ${t.kind}`, t.line, t.col);
693
997
  break;
@@ -699,9 +1003,13 @@ var Parser = class {
699
1003
  trailingDot = true;
700
1004
  continue;
701
1005
  }
1006
+ if (next.kind === "unquoted" && next.value.startsWith(".") && !next.precedingSpace) {
1007
+ trailingDot = true;
1008
+ continue;
1009
+ }
702
1010
  break;
703
1011
  }
704
- return segments;
1012
+ return { segments, firstWasQuoted };
705
1013
  }
706
1014
  parseInclude() {
707
1015
  const p = this.currentPos();
@@ -774,9 +1082,13 @@ var Parser = class {
774
1082
  } else if (t.kind === "lbracket") {
775
1083
  this.advance();
776
1084
  node = this.parseArray();
777
- } else if (t.kind === "subst" || t.kind === "opt_subst") {
1085
+ } else if (t.kind === "subst") {
778
1086
  this.advance();
779
- node = { kind: "subst", path: t.value, optional: t.kind === "opt_subst", pos: { line: t.line, col: t.col } };
1087
+ if (!t.subst) {
1088
+ throw new ParseError(`internal: subst token missing payload`, t.line, t.col);
1089
+ }
1090
+ const payload = t.subst;
1091
+ node = { kind: "subst", segments: payload.segments, optional: payload.optional, listSuffix: payload.listSuffix, pos: { line: t.line, col: t.col } };
780
1092
  } else if (t.kind === "string" || t.kind === "triple_string") {
781
1093
  this.advance();
782
1094
  node = { kind: "scalar", raw: t.value, valueType: "string", pos: { line: t.line, col: t.col } };
@@ -827,6 +1139,20 @@ function parseTokens(tokens) {
827
1139
  return new Parser(tokens).parse();
828
1140
  }
829
1141
 
1142
+ // src/internal/parser/empty-check.ts
1143
+ function hasContentTokens(tokens) {
1144
+ return tokens.some((t) => t.kind !== "eof" && t.kind !== "newline");
1145
+ }
1146
+ function assertNonEmptyDocument(tokens, sourceDescriptor) {
1147
+ if (!hasContentTokens(tokens)) {
1148
+ throw new ParseError(
1149
+ `empty file is not a valid HOCON document (HOCON.md L130): ${sourceDescriptor}`,
1150
+ 1,
1151
+ 1
1152
+ );
1153
+ }
1154
+ }
1155
+
830
1156
  // src/internal/resolver/types.ts
831
1157
  var separatorValues = /* @__PURE__ */ new WeakSet();
832
1158
  function isSubst(v) {
@@ -846,64 +1172,20 @@ function makeResObj() {
846
1172
  }
847
1173
 
848
1174
  // src/internal/resolver/utils.ts
849
- function parseSubstPath(raw) {
850
- const segments = [];
851
- let i = 0;
852
- while (i < raw.length) {
853
- while (i < raw.length && (raw[i] === " " || raw[i] === " ")) i++;
854
- if (i >= raw.length) break;
855
- if (raw[i] === '"') {
856
- i++;
857
- let seg = "";
858
- while (i < raw.length && raw[i] !== '"') {
859
- if (raw[i] === "\\" && i + 1 < raw.length && (raw[i + 1] === '"' || raw[i + 1] === "\\")) {
860
- seg += raw[i + 1];
861
- i += 2;
862
- } else {
863
- seg += raw[i];
864
- i++;
865
- }
866
- }
867
- if (i < raw.length) i++;
868
- segments.push(seg);
869
- while (i < raw.length && (raw[i] === " " || raw[i] === " ")) i++;
870
- if (i < raw.length && raw[i] === ".") i++;
871
- } else if (raw[i] === ".") {
872
- segments.push("");
873
- i++;
874
- } else {
875
- let seg = "";
876
- while (i < raw.length && raw[i] !== ".") {
877
- seg += raw[i];
878
- i++;
879
- }
880
- segments.push(seg.trim());
881
- if (i < raw.length && raw[i] === ".") i++;
882
- }
883
- }
884
- return segments;
885
- }
886
1175
  function segmentsToKey(segments) {
887
1176
  return segments.map((s) => {
888
- if (s === "" || /[^a-zA-Z0-9\-_]/.test(s)) {
889
- return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1177
+ const t = s.text;
1178
+ if (t === "" || /[^a-zA-Z0-9\-_]/.test(t)) {
1179
+ return `"${t.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
890
1180
  }
891
- return s;
1181
+ return t;
892
1182
  }).join(".");
893
1183
  }
894
1184
  function lookupPath(root, segments) {
895
1185
  const [head, ...tail] = segments;
896
- if (head === void 0 || head === "") {
897
- if (segments.length > 0) {
898
- const val2 = root.fields.get("");
899
- if (val2 === void 0) return void 0;
900
- if (tail.length === 0) return val2;
901
- if (isResObj(val2)) return lookupPath(val2, tail);
902
- return void 0;
903
- }
904
- return void 0;
905
- }
906
- const val = root.fields.get(head);
1186
+ if (head === void 0) return void 0;
1187
+ const key = head.text;
1188
+ const val = root.fields.get(key);
907
1189
  if (val === void 0) return void 0;
908
1190
  if (tail.length === 0) return val;
909
1191
  if (isResObj(val)) return lookupPath(val, tail);
@@ -912,7 +1194,7 @@ function lookupPath(root, segments) {
912
1194
  function lookupResObj(root, segments) {
913
1195
  let cur = root;
914
1196
  for (const seg of segments) {
915
- const val = cur.fields.get(seg);
1197
+ const val = cur.fields.get(seg.text);
916
1198
  if (val === void 0 || !isResObj(val)) return void 0;
917
1199
  cur = val;
918
1200
  }
@@ -972,6 +1254,7 @@ var nodePath = __toESM(require("path"), 1);
972
1254
  // src/internal/properties/properties.ts
973
1255
  function parseProperties(input) {
974
1256
  const root = /* @__PURE__ */ Object.create(null);
1257
+ const pairs = [];
975
1258
  for (const line of input.split("\n")) {
976
1259
  const trimmed = line.trim();
977
1260
  if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("!")) continue;
@@ -980,6 +1263,10 @@ function parseProperties(input) {
980
1263
  const key = trimmed.slice(0, sepIdx).trim();
981
1264
  const value = trimmed.slice(sepIdx + 1).trim();
982
1265
  if (key === "") continue;
1266
+ pairs.push([key, value]);
1267
+ }
1268
+ pairs.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1269
+ for (const [key, value] of pairs) {
983
1270
  setNested(root, key.split("."), value);
984
1271
  }
985
1272
  return root;
@@ -1003,6 +1290,7 @@ function setNested(obj, segments, value) {
1003
1290
  }
1004
1291
  const last = segments[segments.length - 1];
1005
1292
  if (last === void 0 || DANGEROUS_KEYS.has(last)) return;
1293
+ if (typeof current[last] === "object" && current[last] !== null) return;
1006
1294
  current[last] = value;
1007
1295
  }
1008
1296
  function propertiesToHoconValue(input) {
@@ -1116,7 +1404,9 @@ var IncludeLoader = class {
1116
1404
  if (candidate.endsWith(".properties")) {
1117
1405
  return hoconValueToResObj(propertiesToHoconValue(content));
1118
1406
  }
1119
- const ast = parseTokens(tokenize(content));
1407
+ const tokens = tokenize(content);
1408
+ assertNonEmptyDocument(tokens, candidate);
1409
+ const ast = parseTokens(tokens);
1120
1410
  return this.onBuildResObj(ast, {
1121
1411
  env,
1122
1412
  baseDir: nodePath.dirname(candidate),
@@ -1140,7 +1430,9 @@ var IncludeLoader = class {
1140
1430
  if (candidate.endsWith(".properties")) {
1141
1431
  return hoconValueToResObj(propertiesToHoconValue(content));
1142
1432
  }
1143
- const ast = parseTokens(tokenize(content));
1433
+ const tokens = tokenize(content);
1434
+ assertNonEmptyDocument(tokens, candidate);
1435
+ const ast = parseTokens(tokens);
1144
1436
  return this.onBuildResObjAsync(ast, {
1145
1437
  env,
1146
1438
  baseDir: nodePath.dirname(candidate),
@@ -1189,7 +1481,7 @@ var StructureBuilder = class _StructureBuilder {
1189
1481
  return;
1190
1482
  }
1191
1483
  const [head, ...tail] = field.key;
1192
- if (!head) return;
1484
+ if (head === void 0) return;
1193
1485
  if (tail.length > 0) {
1194
1486
  const syntheticAst = {
1195
1487
  kind: "object",
@@ -1231,7 +1523,7 @@ var StructureBuilder = class _StructureBuilder {
1231
1523
  return;
1232
1524
  }
1233
1525
  const [head, ...tail] = field.key;
1234
- if (!head) return;
1526
+ if (head === void 0) return;
1235
1527
  if (tail.length > 0) {
1236
1528
  const syntheticAst = {
1237
1529
  kind: "object",
@@ -1277,9 +1569,9 @@ var StructureBuilder = class _StructureBuilder {
1277
1569
  return inner;
1278
1570
  }
1279
1571
  case "subst":
1280
- return { _kind: "subst-placeholder", segments: parseSubstPath(ast.path), optional: ast.optional, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1572
+ return { _kind: "subst-placeholder", segments: ast.segments, optional: ast.optional, listSuffix: ast.listSuffix, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1281
1573
  case "concat":
1282
- return { _kind: "concat-placeholder", nodes: ast.nodes.map((n) => this.astToResolverValue(n, pathPrefix)) };
1574
+ return { _kind: "concat-placeholder", nodes: ast.nodes.map((n) => this.astToResolverValue(n, pathPrefix)), line: ast.pos.line, col: ast.pos.col };
1283
1575
  case "include":
1284
1576
  return { kind: "scalar", raw: "null", valueType: "null" };
1285
1577
  }
@@ -1301,13 +1593,13 @@ var StructureBuilder = class _StructureBuilder {
1301
1593
  case "object":
1302
1594
  return await this.buildAsync(ast, pathPrefix);
1303
1595
  case "subst":
1304
- return { _kind: "subst-placeholder", segments: parseSubstPath(ast.path), optional: ast.optional, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1596
+ return { _kind: "subst-placeholder", segments: ast.segments, optional: ast.optional, listSuffix: ast.listSuffix, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1305
1597
  case "concat": {
1306
1598
  const nodes = [];
1307
1599
  for (const n of ast.nodes) {
1308
1600
  nodes.push(await this.astToResolverValueAsync(n, pathPrefix));
1309
1601
  }
1310
- return { _kind: "concat-placeholder", nodes };
1602
+ return { _kind: "concat-placeholder", nodes, line: ast.pos.line, col: ast.pos.col };
1311
1603
  }
1312
1604
  case "include":
1313
1605
  return { kind: "scalar", raw: "null", valueType: "null" };
@@ -1316,7 +1608,8 @@ var StructureBuilder = class _StructureBuilder {
1316
1608
  // ---- Relativize substitution paths for nested includes ----
1317
1609
  relativizeSubstPaths(val, prefixSegments) {
1318
1610
  if (isSubst(val)) {
1319
- val.segments = [...prefixSegments, ...val.segments];
1611
+ const prefixAsSegments = prefixSegments.map((text) => ({ text, line: 0, col: 0 }));
1612
+ val.segments = [...prefixAsSegments, ...val.segments];
1320
1613
  val.prefixLen += prefixSegments.length;
1321
1614
  return;
1322
1615
  }
@@ -1359,6 +1652,19 @@ var SubstitutionResolver = class {
1359
1652
  this.opts = opts;
1360
1653
  this.resolving = /* @__PURE__ */ new Set();
1361
1654
  this.cache = /* @__PURE__ */ new Map();
1655
+ // Tracks ConcatPlaceholder nodes currently being iterated by resolveConcat.
1656
+ // Used by isSelfRef detection: fires only when found === a concat currently
1657
+ // mid-iteration (i.e. we are inside found's own resolution), preventing the
1658
+ // "foofoo" double-count without misfiring when an external field looks up the
1659
+ // same concat via a substitution (multi-reviewer convergence fix).
1660
+ //
1661
+ // Spec deviation: the S13a.13 spec ★1 decision #1 specified path-equality
1662
+ // preservation for self-ref detection. Round-2 multi-agent-review surfaced a
1663
+ // false-positive on external lookups (`a = ${?a}foo; b = ${a}`), so the
1664
+ // criterion was tightened to node-membership-in-iterating-set — strictly
1665
+ // narrower than path-equality. Spec amendment deferred to a follow-up
1666
+ // xx.hocon PR (see Phase 6 #3f close-out notes).
1667
+ this.resolvingConcats = /* @__PURE__ */ new WeakSet();
1362
1668
  }
1363
1669
  resolve() {
1364
1670
  return this.resolveResObj(this.root);
@@ -1397,7 +1703,14 @@ var SubstitutionResolver = class {
1397
1703
  }
1398
1704
  resolveVal(v, scope) {
1399
1705
  if (isSubst(v)) return this.resolveSubst(v, scope);
1400
- if (isConcat(v)) return this.resolveConcat(v.nodes, scope);
1706
+ if (isConcat(v)) {
1707
+ this.resolvingConcats.add(v);
1708
+ try {
1709
+ return this.resolveConcat(v.nodes, scope, v.line, v.col);
1710
+ } finally {
1711
+ this.resolvingConcats.delete(v);
1712
+ }
1713
+ }
1401
1714
  if (isAppend(v)) return this.resolveAppend(v, scope);
1402
1715
  if (isResObj(v)) return this.resolveResObj(v);
1403
1716
  const hv = v;
@@ -1411,23 +1724,20 @@ var SubstitutionResolver = class {
1411
1724
  }
1412
1725
  return hv;
1413
1726
  }
1414
- segmentsEqual(a, b) {
1415
- return a.length === b.length && a.every((seg, i) => seg === b[i]);
1416
- }
1417
1727
  resolveSubst(s, scope) {
1418
- const key = segmentsToKey(s.segments);
1728
+ const key = s.listSuffix ? `${segmentsToKey(s.segments)}[]` : segmentsToKey(s.segments);
1419
1729
  if (this.cache.has(key)) return this.cache.get(key);
1420
1730
  if (this.resolving.has(key)) {
1421
1731
  let prior;
1422
- if (s.prefixLen > 0) {
1423
- const leafSeg = s.segments[s.segments.length - 1] ?? "";
1732
+ if (s.segments.length > 1) {
1733
+ const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1424
1734
  const parentScope = lookupResObj(
1425
1735
  this.root,
1426
1736
  s.segments.slice(0, s.segments.length - 1)
1427
1737
  );
1428
1738
  prior = parentScope?.priorValues.get(leafSeg);
1429
1739
  } else {
1430
- const rootSeg = s.segments[0] ?? "";
1740
+ const rootSeg = s.segments[0]?.text ?? "";
1431
1741
  prior = scope.priorValues.get(rootSeg) ?? this.root.priorValues.get(rootSeg);
1432
1742
  }
1433
1743
  if (prior !== void 0) {
@@ -1451,34 +1761,36 @@ var SubstitutionResolver = class {
1451
1761
  try {
1452
1762
  const found = lookupPath(this.root, s.segments);
1453
1763
  if (found !== void 0) {
1454
- if (isSubst(found) || isConcat(found)) {
1455
- const isSelfRef = isSubst(found) ? this.segmentsEqual(found.segments, s.segments) : isConcat(found) && found.nodes.some(
1456
- (n) => isSubst(n) && this.segmentsEqual(n.segments, s.segments)
1457
- );
1458
- if (isSelfRef) {
1459
- let prior;
1460
- if (s.prefixLen > 0) {
1461
- const leafSeg = s.segments[s.segments.length - 1] ?? "";
1462
- const parentScope = lookupResObj(
1463
- this.root,
1464
- s.segments.slice(0, s.segments.length - 1)
1465
- );
1466
- prior = parentScope?.priorValues.get(leafSeg);
1467
- } else {
1468
- const rootSeg = s.segments[0] ?? "";
1469
- prior = scope.priorValues.get(rootSeg) ?? this.root.priorValues.get(rootSeg);
1470
- }
1471
- if (prior !== void 0) {
1472
- const result2 = this.resolveVal(prior, scope);
1473
- if (result2 !== void 0) this.cache.set(key, result2);
1474
- return result2;
1475
- }
1764
+ if (isConcat(found) && this.resolvingConcats.has(found)) {
1765
+ let prior;
1766
+ if (s.segments.length > 1) {
1767
+ const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1768
+ const parentScope = lookupResObj(
1769
+ this.root,
1770
+ s.segments.slice(0, s.segments.length - 1)
1771
+ );
1772
+ prior = parentScope?.priorValues.get(leafSeg);
1773
+ } else {
1774
+ const rootSeg = s.segments[0]?.text ?? "";
1775
+ prior = scope.priorValues.get(rootSeg) ?? this.root.priorValues.get(rootSeg);
1776
+ }
1777
+ if (prior !== void 0) {
1778
+ const result2 = this.resolveVal(prior, scope);
1779
+ if (result2 !== void 0) this.cache.set(key, result2);
1780
+ return result2;
1476
1781
  }
1782
+ if (s.optional) return void 0;
1783
+ throw new ResolveError(
1784
+ `could not resolve substitution: \${${key}}`,
1785
+ key,
1786
+ s.line,
1787
+ s.col
1788
+ );
1477
1789
  }
1478
1790
  let result = this.resolveVal(found, scope);
1479
1791
  const effectiveLen = s.segments.length - s.prefixLen;
1480
1792
  if (effectiveLen === 1 && result !== void 0 && result.kind === "object") {
1481
- const leafSeg = s.segments[s.segments.length - 1] ?? "";
1793
+ const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1482
1794
  let prior;
1483
1795
  if (s.prefixLen > 0) {
1484
1796
  const parentScope = lookupResObj(
@@ -1502,8 +1814,23 @@ var SubstitutionResolver = class {
1502
1814
  if (result !== void 0) this.cache.set(key, result);
1503
1815
  return result;
1504
1816
  }
1505
- const envKey = s.segments.join(".");
1506
- const envVal = this.opts.env[envKey] ?? (s.prefixLen > 0 ? this.opts.env[s.segments.slice(s.prefixLen).join(".")] : void 0);
1817
+ if (s.listSuffix) {
1818
+ const result = this.resolveEnvList(s);
1819
+ if (result !== void 0) {
1820
+ this.cache.set(key, result);
1821
+ return result;
1822
+ }
1823
+ if (s.optional) return void 0;
1824
+ const envBase = s.segments.map((seg) => seg.text).join(".");
1825
+ throw new ResolveError(
1826
+ `could not resolve substitution: \${${key}} (no environment variables ${envBase}_0, ${envBase}_1, \u2026 set)`,
1827
+ key,
1828
+ s.line,
1829
+ s.col
1830
+ );
1831
+ }
1832
+ const envKey = s.segments.map((seg) => seg.text).join(".");
1833
+ const envVal = this.opts.env[envKey] ?? (s.prefixLen > 0 ? this.opts.env[s.segments.slice(s.prefixLen).map((seg) => seg.text).join(".")] : void 0);
1507
1834
  if (envVal !== void 0) {
1508
1835
  const result = { kind: "scalar", raw: envVal, valueType: "string" };
1509
1836
  this.cache.set(key, result);
@@ -1520,37 +1847,106 @@ var SubstitutionResolver = class {
1520
1847
  this.resolving.delete(key);
1521
1848
  }
1522
1849
  }
1523
- resolveConcat(nodes, scope) {
1850
+ /**
1851
+ * S13c: scan environment for NAME_0, NAME_1, … and return an Array<Scalar(String)>.
1852
+ *
1853
+ * Candidate bases follow the same fully-qualified→bare order used by the scalar
1854
+ * env fallback. The first base whose _0 key is present wins entirely — no
1855
+ * cross-base merging (spec §Relativized fallback precedence rules).
1856
+ *
1857
+ * Returns the HoconValue Array if any candidate base has _0, or undefined if
1858
+ * none do (caller applies optional/required handling).
1859
+ */
1860
+ resolveEnvList(s) {
1861
+ const bases = this.candidateBases(s);
1862
+ for (const base of bases) {
1863
+ const key0 = base + "_0";
1864
+ if (!(key0 in this.opts.env)) continue;
1865
+ const items = [];
1866
+ for (let i = 0; ; i++) {
1867
+ const k = base + "_" + i;
1868
+ if (!(k in this.opts.env)) break;
1869
+ items.push({ kind: "scalar", raw: this.opts.env[k], valueType: "string" });
1870
+ }
1871
+ return { kind: "array", items };
1872
+ }
1873
+ return void 0;
1874
+ }
1875
+ /**
1876
+ * Return the candidate base names for env-var-list lookup, in precedence order.
1877
+ * Mirrors the fully-qualified→bare fallback used by the scalar env path.
1878
+ */
1879
+ candidateBases(s) {
1880
+ const full = s.segments.map((seg) => seg.text).join(".");
1881
+ if (s.prefixLen === 0) return [full];
1882
+ const bare = s.segments.slice(s.prefixLen).map((seg) => seg.text).join(".");
1883
+ return [full, bare];
1884
+ }
1885
+ resolveConcat(nodes, scope, line = 0, col = 0) {
1524
1886
  const resolved = nodes.map((n) => this.resolveVal(n, scope)).filter((v) => v !== void 0);
1525
1887
  if (resolved.length === 0) return { kind: "scalar", raw: "null", valueType: "null" };
1526
1888
  if (resolved.length === 1) return resolved[0];
1527
1889
  const nonSep = resolved.filter((v) => !separatorValues.has(v));
1528
- if (nonSep.length > 0 && nonSep.every((v) => v.kind === "object")) {
1529
- const merged = /* @__PURE__ */ new Map();
1530
- for (const v of nonSep) {
1531
- if (v.kind === "object") {
1532
- for (const [k, val] of v.fields) {
1533
- const existing = merged.get(k);
1534
- if (existing?.kind === "object" && val.kind === "object") {
1535
- merged.set(k, deepMergeHoconValues(existing, val));
1536
- } else {
1537
- merged.set(k, val);
1538
- }
1539
- }
1890
+ if (nonSep.length === 0) {
1891
+ const str = resolved.map((v) => v.kind === "scalar" ? v.raw : JSON.stringify(v)).join("");
1892
+ return { kind: "scalar", raw: str, valueType: "string" };
1893
+ }
1894
+ const joinPair = (left, right) => {
1895
+ if (left.kind === "object" && right.kind === "object") {
1896
+ return deepMergeHoconValues(left, right);
1897
+ }
1898
+ if (left.kind === "array" && right.kind === "object") {
1899
+ const converted = numericObjectToArray(right);
1900
+ if (converted !== null) {
1901
+ return { kind: "array", items: [...left.items, ...converted] };
1540
1902
  }
1903
+ throw new ResolveError(
1904
+ "cannot concatenate array with object: value concatenation requires same-kind operands (S10.4)",
1905
+ "",
1906
+ line,
1907
+ col
1908
+ );
1541
1909
  }
1542
- return { kind: "object", fields: merged };
1543
- }
1544
- if (resolved.some((v) => v.kind === "array")) {
1545
- const items = [];
1546
- for (const v of resolved) {
1547
- if (v.kind === "array") items.push(...v.items);
1548
- else items.push(v);
1910
+ if (left.kind === "object" && right.kind === "array") {
1911
+ const converted = numericObjectToArray(left);
1912
+ if (converted !== null) {
1913
+ return { kind: "array", items: [...converted, ...right.items] };
1914
+ }
1915
+ throw new ResolveError(
1916
+ "cannot concatenate object with array: value concatenation requires same-kind operands (S10.4)",
1917
+ "",
1918
+ line,
1919
+ col
1920
+ );
1549
1921
  }
1550
- return { kind: "array", items };
1922
+ if (left.kind === "array" && right.kind === "array") {
1923
+ return { kind: "array", items: [...left.items, ...right.items] };
1924
+ }
1925
+ if (left.kind === "array" || right.kind === "array") {
1926
+ throw new ResolveError(
1927
+ `cannot concatenate ${left.kind} with ${right.kind}: arrays and objects may not appear in string value concatenation (S10.13)`,
1928
+ "",
1929
+ line,
1930
+ col
1931
+ );
1932
+ }
1933
+ if (left.kind === "object" || right.kind === "object") {
1934
+ throw new ResolveError(
1935
+ `cannot concatenate ${left.kind} with ${right.kind}: arrays and objects may not appear in string value concatenation (S10.13)`,
1936
+ "",
1937
+ line,
1938
+ col
1939
+ );
1940
+ }
1941
+ return { kind: "scalar", raw: left.raw + right.raw, valueType: "string" };
1942
+ };
1943
+ const [head, ...tail] = nonSep;
1944
+ const folded = tail.reduce(joinPair, head);
1945
+ if (folded.kind === "scalar") {
1946
+ const str = resolved.map((v) => v.kind === "scalar" ? v.raw : JSON.stringify(v)).join("");
1947
+ return { kind: "scalar", raw: str, valueType: "string" };
1551
1948
  }
1552
- const str = resolved.map((v) => v.kind === "scalar" ? v.raw : JSON.stringify(v)).join("");
1553
- return { kind: "scalar", raw: str, valueType: "string" };
1949
+ return folded;
1554
1950
  }
1555
1951
  resolveAppend(a, scope) {
1556
1952
  const existing = this.resolveVal(a.existing, scope) ?? { kind: "array", items: [] };
@@ -1587,6 +1983,7 @@ async function defaultReadFile(filePath) {
1587
1983
  }
1588
1984
  function buildResolveContext(input, opts) {
1589
1985
  const tokens = tokenize(input);
1986
+ assertNonEmptyDocument(tokens, "input");
1590
1987
  const ast = parseTokens(tokens);
1591
1988
  const resolveOpts = {
1592
1989
  env: getEnv(opts),