@o3co/ts.hocon 1.2.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
  }
@@ -267,6 +325,7 @@ var Config = class _Config {
267
325
  }
268
326
  const result = parseBytes(v.raw, unit);
269
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);
270
329
  return result;
271
330
  }
272
331
  getConfig(path2) {
@@ -520,14 +579,6 @@ var Lexer = class {
520
579
  continue;
521
580
  }
522
581
  if (isUnquotedStart(ch)) {
523
- if (ch === "-" && !isDecimalDigit(this.peek(1))) {
524
- const after = this.peek(1) === "" ? "EOF" : JSON.stringify(this.peek(1));
525
- throw new ParseError(
526
- `unquoted string cannot begin with '-' unless followed by a digit (got '-' then ${after}, HOCON.md L270-276)`,
527
- sl,
528
- sc
529
- );
530
- }
531
582
  let value = "";
532
583
  while (this.pos < this.input.length && isUnquotedContinue(this.peek(), () => this.peek(1))) {
533
584
  value += this.advance();
@@ -631,6 +682,10 @@ var Lexer = class {
631
682
  /**
632
683
  * Parse the body of a `${...}` substitution (called after `${` has been consumed).
633
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).
634
689
  */
635
690
  parseSubstBody(startLine, startCol) {
636
691
  let optional = false;
@@ -645,6 +700,7 @@ var Lexer = class {
645
700
  let pendingWs = "";
646
701
  const segments = [];
647
702
  let lastDot = null;
703
+ let listSuffix = false;
648
704
  while (true) {
649
705
  if (this.pos >= this.input.length) {
650
706
  throw new ParseError("unterminated substitution", startLine, startCol);
@@ -654,6 +710,23 @@ var Lexer = class {
654
710
  this.advance();
655
711
  pendingWs = "";
656
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;
657
730
  } else if (ch === '"') {
658
731
  const qLine = startLine;
659
732
  const qCol = this.col;
@@ -713,15 +786,47 @@ var Lexer = class {
713
786
  throw new ParseError(`unexpected character in substitution path: ${JSON.stringify(ch)}`, startLine, this.col);
714
787
  }
715
788
  }
716
- if (curStarted) {
717
- segments.push({ text: curText, line: curLine, col: curCol });
718
- } else if (segments.length === 0) {
719
- throw new ParseError("empty substitution path", startLine, startCol);
720
- } else {
721
- const [errLine, errCol] = lastDot ?? [startLine, startCol];
722
- throw new ParseError("empty segment in path", errLine, errCol);
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
+ }
723
798
  }
724
- return { segments, optional };
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;
725
830
  }
726
831
  };
727
832
  function tokenize(input) {
@@ -809,6 +914,14 @@ var Parser = class {
809
914
  const t = this.peek();
810
915
  if (t.kind === "eof" || t.kind === "rbrace") break;
811
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
+ }
812
925
  this.advance();
813
926
  fields.push(this.parseInclude());
814
927
  this.skip("newline");
@@ -817,7 +930,14 @@ var Parser = class {
817
930
  continue;
818
931
  }
819
932
  const keyPos = this.currentPos();
820
- 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
+ }
821
941
  this.skip("newline");
822
942
  let append = false;
823
943
  const sep = this.peek();
@@ -847,10 +967,12 @@ var Parser = class {
847
967
  parseKey() {
848
968
  const segments = [];
849
969
  let trailingDot = false;
970
+ let firstWasQuoted = false;
850
971
  while (true) {
851
972
  const t = this.peek();
852
973
  if (t.kind === "string") {
853
974
  this.advance();
975
+ if (segments.length === 0) firstWasQuoted = true;
854
976
  segments.push(t.value);
855
977
  trailingDot = false;
856
978
  } else if (t.kind === "unquoted") {
@@ -887,7 +1009,7 @@ var Parser = class {
887
1009
  }
888
1010
  break;
889
1011
  }
890
- return segments;
1012
+ return { segments, firstWasQuoted };
891
1013
  }
892
1014
  parseInclude() {
893
1015
  const p = this.currentPos();
@@ -966,7 +1088,7 @@ var Parser = class {
966
1088
  throw new ParseError(`internal: subst token missing payload`, t.line, t.col);
967
1089
  }
968
1090
  const payload = t.subst;
969
- node = { kind: "subst", segments: payload.segments, optional: payload.optional, pos: { line: t.line, col: t.col } };
1091
+ node = { kind: "subst", segments: payload.segments, optional: payload.optional, listSuffix: payload.listSuffix, pos: { line: t.line, col: t.col } };
970
1092
  } else if (t.kind === "string" || t.kind === "triple_string") {
971
1093
  this.advance();
972
1094
  node = { kind: "scalar", raw: t.value, valueType: "string", pos: { line: t.line, col: t.col } };
@@ -1017,6 +1139,20 @@ function parseTokens(tokens) {
1017
1139
  return new Parser(tokens).parse();
1018
1140
  }
1019
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
+
1020
1156
  // src/internal/resolver/types.ts
1021
1157
  var separatorValues = /* @__PURE__ */ new WeakSet();
1022
1158
  function isSubst(v) {
@@ -1118,6 +1254,7 @@ var nodePath = __toESM(require("path"), 1);
1118
1254
  // src/internal/properties/properties.ts
1119
1255
  function parseProperties(input) {
1120
1256
  const root = /* @__PURE__ */ Object.create(null);
1257
+ const pairs = [];
1121
1258
  for (const line of input.split("\n")) {
1122
1259
  const trimmed = line.trim();
1123
1260
  if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("!")) continue;
@@ -1126,6 +1263,10 @@ function parseProperties(input) {
1126
1263
  const key = trimmed.slice(0, sepIdx).trim();
1127
1264
  const value = trimmed.slice(sepIdx + 1).trim();
1128
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) {
1129
1270
  setNested(root, key.split("."), value);
1130
1271
  }
1131
1272
  return root;
@@ -1149,6 +1290,7 @@ function setNested(obj, segments, value) {
1149
1290
  }
1150
1291
  const last = segments[segments.length - 1];
1151
1292
  if (last === void 0 || DANGEROUS_KEYS.has(last)) return;
1293
+ if (typeof current[last] === "object" && current[last] !== null) return;
1152
1294
  current[last] = value;
1153
1295
  }
1154
1296
  function propertiesToHoconValue(input) {
@@ -1262,7 +1404,9 @@ var IncludeLoader = class {
1262
1404
  if (candidate.endsWith(".properties")) {
1263
1405
  return hoconValueToResObj(propertiesToHoconValue(content));
1264
1406
  }
1265
- const ast = parseTokens(tokenize(content));
1407
+ const tokens = tokenize(content);
1408
+ assertNonEmptyDocument(tokens, candidate);
1409
+ const ast = parseTokens(tokens);
1266
1410
  return this.onBuildResObj(ast, {
1267
1411
  env,
1268
1412
  baseDir: nodePath.dirname(candidate),
@@ -1286,7 +1430,9 @@ var IncludeLoader = class {
1286
1430
  if (candidate.endsWith(".properties")) {
1287
1431
  return hoconValueToResObj(propertiesToHoconValue(content));
1288
1432
  }
1289
- const ast = parseTokens(tokenize(content));
1433
+ const tokens = tokenize(content);
1434
+ assertNonEmptyDocument(tokens, candidate);
1435
+ const ast = parseTokens(tokens);
1290
1436
  return this.onBuildResObjAsync(ast, {
1291
1437
  env,
1292
1438
  baseDir: nodePath.dirname(candidate),
@@ -1423,9 +1569,9 @@ var StructureBuilder = class _StructureBuilder {
1423
1569
  return inner;
1424
1570
  }
1425
1571
  case "subst":
1426
- return { _kind: "subst-placeholder", segments: ast.segments, 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 };
1427
1573
  case "concat":
1428
- 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 };
1429
1575
  case "include":
1430
1576
  return { kind: "scalar", raw: "null", valueType: "null" };
1431
1577
  }
@@ -1447,13 +1593,13 @@ var StructureBuilder = class _StructureBuilder {
1447
1593
  case "object":
1448
1594
  return await this.buildAsync(ast, pathPrefix);
1449
1595
  case "subst":
1450
- return { _kind: "subst-placeholder", segments: ast.segments, 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 };
1451
1597
  case "concat": {
1452
1598
  const nodes = [];
1453
1599
  for (const n of ast.nodes) {
1454
1600
  nodes.push(await this.astToResolverValueAsync(n, pathPrefix));
1455
1601
  }
1456
- return { _kind: "concat-placeholder", nodes };
1602
+ return { _kind: "concat-placeholder", nodes, line: ast.pos.line, col: ast.pos.col };
1457
1603
  }
1458
1604
  case "include":
1459
1605
  return { kind: "scalar", raw: "null", valueType: "null" };
@@ -1506,6 +1652,19 @@ var SubstitutionResolver = class {
1506
1652
  this.opts = opts;
1507
1653
  this.resolving = /* @__PURE__ */ new Set();
1508
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();
1509
1668
  }
1510
1669
  resolve() {
1511
1670
  return this.resolveResObj(this.root);
@@ -1544,7 +1703,14 @@ var SubstitutionResolver = class {
1544
1703
  }
1545
1704
  resolveVal(v, scope) {
1546
1705
  if (isSubst(v)) return this.resolveSubst(v, scope);
1547
- 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
+ }
1548
1714
  if (isAppend(v)) return this.resolveAppend(v, scope);
1549
1715
  if (isResObj(v)) return this.resolveResObj(v);
1550
1716
  const hv = v;
@@ -1558,15 +1724,12 @@ var SubstitutionResolver = class {
1558
1724
  }
1559
1725
  return hv;
1560
1726
  }
1561
- segmentsEqual(a, b) {
1562
- return a.length === b.length && a.every((seg, i) => seg.text === b[i]?.text);
1563
- }
1564
1727
  resolveSubst(s, scope) {
1565
- const key = segmentsToKey(s.segments);
1728
+ const key = s.listSuffix ? `${segmentsToKey(s.segments)}[]` : segmentsToKey(s.segments);
1566
1729
  if (this.cache.has(key)) return this.cache.get(key);
1567
1730
  if (this.resolving.has(key)) {
1568
1731
  let prior;
1569
- if (s.prefixLen > 0) {
1732
+ if (s.segments.length > 1) {
1570
1733
  const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1571
1734
  const parentScope = lookupResObj(
1572
1735
  this.root,
@@ -1598,29 +1761,31 @@ var SubstitutionResolver = class {
1598
1761
  try {
1599
1762
  const found = lookupPath(this.root, s.segments);
1600
1763
  if (found !== void 0) {
1601
- if (isSubst(found) || isConcat(found)) {
1602
- const isSelfRef = isSubst(found) ? this.segmentsEqual(found.segments, s.segments) : isConcat(found) && found.nodes.some(
1603
- (n) => isSubst(n) && this.segmentsEqual(n.segments, s.segments)
1604
- );
1605
- if (isSelfRef) {
1606
- let prior;
1607
- if (s.prefixLen > 0) {
1608
- const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1609
- const parentScope = lookupResObj(
1610
- this.root,
1611
- s.segments.slice(0, s.segments.length - 1)
1612
- );
1613
- prior = parentScope?.priorValues.get(leafSeg);
1614
- } else {
1615
- const rootSeg = s.segments[0]?.text ?? "";
1616
- prior = scope.priorValues.get(rootSeg) ?? this.root.priorValues.get(rootSeg);
1617
- }
1618
- if (prior !== void 0) {
1619
- const result2 = this.resolveVal(prior, scope);
1620
- if (result2 !== void 0) this.cache.set(key, result2);
1621
- return result2;
1622
- }
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);
1623
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;
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
+ );
1624
1789
  }
1625
1790
  let result = this.resolveVal(found, scope);
1626
1791
  const effectiveLen = s.segments.length - s.prefixLen;
@@ -1649,6 +1814,21 @@ var SubstitutionResolver = class {
1649
1814
  if (result !== void 0) this.cache.set(key, result);
1650
1815
  return result;
1651
1816
  }
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
+ }
1652
1832
  const envKey = s.segments.map((seg) => seg.text).join(".");
1653
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);
1654
1834
  if (envVal !== void 0) {
@@ -1667,7 +1847,42 @@ var SubstitutionResolver = class {
1667
1847
  this.resolving.delete(key);
1668
1848
  }
1669
1849
  }
1670
- 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) {
1671
1886
  const resolved = nodes.map((n) => this.resolveVal(n, scope)).filter((v) => v !== void 0);
1672
1887
  if (resolved.length === 0) return { kind: "scalar", raw: "null", valueType: "null" };
1673
1888
  if (resolved.length === 1) return resolved[0];
@@ -1685,27 +1900,45 @@ var SubstitutionResolver = class {
1685
1900
  if (converted !== null) {
1686
1901
  return { kind: "array", items: [...left.items, ...converted] };
1687
1902
  }
1688
- return { kind: "array", items: [...left.items, right] };
1903
+ throw new ResolveError(
1904
+ "cannot concatenate array with object: value concatenation requires same-kind operands (S10.4)",
1905
+ "",
1906
+ line,
1907
+ col
1908
+ );
1689
1909
  }
1690
1910
  if (left.kind === "object" && right.kind === "array") {
1691
1911
  const converted = numericObjectToArray(left);
1692
1912
  if (converted !== null) {
1693
1913
  return { kind: "array", items: [...converted, ...right.items] };
1694
1914
  }
1695
- return { kind: "array", items: [left, ...right.items] };
1915
+ throw new ResolveError(
1916
+ "cannot concatenate object with array: value concatenation requires same-kind operands (S10.4)",
1917
+ "",
1918
+ line,
1919
+ col
1920
+ );
1696
1921
  }
1697
1922
  if (left.kind === "array" && right.kind === "array") {
1698
1923
  return { kind: "array", items: [...left.items, ...right.items] };
1699
1924
  }
1700
- if (left.kind === "array") {
1701
- return { kind: "array", items: [...left.items, right] };
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
+ );
1702
1932
  }
1703
- if (right.kind === "array") {
1704
- return { kind: "array", items: [left, ...right.items] };
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
+ );
1705
1940
  }
1706
- const leftStr = left.kind === "scalar" ? left.raw : JSON.stringify(left);
1707
- const rightStr = right.kind === "scalar" ? right.raw : JSON.stringify(right);
1708
- return { kind: "scalar", raw: leftStr + rightStr, valueType: "string" };
1941
+ return { kind: "scalar", raw: left.raw + right.raw, valueType: "string" };
1709
1942
  };
1710
1943
  const [head, ...tail] = nonSep;
1711
1944
  const folded = tail.reduce(joinPair, head);
@@ -1750,6 +1983,7 @@ async function defaultReadFile(filePath) {
1750
1983
  }
1751
1984
  function buildResolveContext(input, opts) {
1752
1985
  const tokens = tokenize(input);
1986
+ assertNonEmptyDocument(tokens, "input");
1753
1987
  const ast = parseTokens(tokens);
1754
1988
  const resolveOpts = {
1755
1989
  env: getEnv(opts),