@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.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
  }
@@ -224,6 +282,7 @@ var Config = class _Config {
224
282
  }
225
283
  const result = parseBytes(v.raw, unit);
226
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);
227
286
  return result;
228
287
  }
229
288
  getConfig(path2) {
@@ -477,14 +536,6 @@ var Lexer = class {
477
536
  continue;
478
537
  }
479
538
  if (isUnquotedStart(ch)) {
480
- if (ch === "-" && !isDecimalDigit(this.peek(1))) {
481
- const after = this.peek(1) === "" ? "EOF" : JSON.stringify(this.peek(1));
482
- throw new ParseError(
483
- `unquoted string cannot begin with '-' unless followed by a digit (got '-' then ${after}, HOCON.md L270-276)`,
484
- sl,
485
- sc
486
- );
487
- }
488
539
  let value = "";
489
540
  while (this.pos < this.input.length && isUnquotedContinue(this.peek(), () => this.peek(1))) {
490
541
  value += this.advance();
@@ -588,6 +639,10 @@ var Lexer = class {
588
639
  /**
589
640
  * Parse the body of a `${...}` substitution (called after `${` has been consumed).
590
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).
591
646
  */
592
647
  parseSubstBody(startLine, startCol) {
593
648
  let optional = false;
@@ -602,6 +657,7 @@ var Lexer = class {
602
657
  let pendingWs = "";
603
658
  const segments = [];
604
659
  let lastDot = null;
660
+ let listSuffix = false;
605
661
  while (true) {
606
662
  if (this.pos >= this.input.length) {
607
663
  throw new ParseError("unterminated substitution", startLine, startCol);
@@ -611,6 +667,23 @@ var Lexer = class {
611
667
  this.advance();
612
668
  pendingWs = "";
613
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;
614
687
  } else if (ch === '"') {
615
688
  const qLine = startLine;
616
689
  const qCol = this.col;
@@ -670,15 +743,47 @@ var Lexer = class {
670
743
  throw new ParseError(`unexpected character in substitution path: ${JSON.stringify(ch)}`, startLine, this.col);
671
744
  }
672
745
  }
673
- if (curStarted) {
674
- segments.push({ text: curText, line: curLine, col: curCol });
675
- } else if (segments.length === 0) {
676
- throw new ParseError("empty substitution path", startLine, startCol);
677
- } else {
678
- const [errLine, errCol] = lastDot ?? [startLine, startCol];
679
- throw new ParseError("empty segment in path", errLine, errCol);
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
+ }
680
755
  }
681
- return { segments, optional };
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;
682
787
  }
683
788
  };
684
789
  function tokenize(input) {
@@ -766,6 +871,14 @@ var Parser = class {
766
871
  const t = this.peek();
767
872
  if (t.kind === "eof" || t.kind === "rbrace") break;
768
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
+ }
769
882
  this.advance();
770
883
  fields.push(this.parseInclude());
771
884
  this.skip("newline");
@@ -774,7 +887,14 @@ var Parser = class {
774
887
  continue;
775
888
  }
776
889
  const keyPos = this.currentPos();
777
- 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
+ }
778
898
  this.skip("newline");
779
899
  let append = false;
780
900
  const sep = this.peek();
@@ -804,10 +924,12 @@ var Parser = class {
804
924
  parseKey() {
805
925
  const segments = [];
806
926
  let trailingDot = false;
927
+ let firstWasQuoted = false;
807
928
  while (true) {
808
929
  const t = this.peek();
809
930
  if (t.kind === "string") {
810
931
  this.advance();
932
+ if (segments.length === 0) firstWasQuoted = true;
811
933
  segments.push(t.value);
812
934
  trailingDot = false;
813
935
  } else if (t.kind === "unquoted") {
@@ -844,7 +966,7 @@ var Parser = class {
844
966
  }
845
967
  break;
846
968
  }
847
- return segments;
969
+ return { segments, firstWasQuoted };
848
970
  }
849
971
  parseInclude() {
850
972
  const p = this.currentPos();
@@ -923,7 +1045,7 @@ var Parser = class {
923
1045
  throw new ParseError(`internal: subst token missing payload`, t.line, t.col);
924
1046
  }
925
1047
  const payload = t.subst;
926
- node = { kind: "subst", segments: payload.segments, optional: payload.optional, pos: { line: t.line, col: t.col } };
1048
+ node = { kind: "subst", segments: payload.segments, optional: payload.optional, listSuffix: payload.listSuffix, pos: { line: t.line, col: t.col } };
927
1049
  } else if (t.kind === "string" || t.kind === "triple_string") {
928
1050
  this.advance();
929
1051
  node = { kind: "scalar", raw: t.value, valueType: "string", pos: { line: t.line, col: t.col } };
@@ -974,6 +1096,20 @@ function parseTokens(tokens) {
974
1096
  return new Parser(tokens).parse();
975
1097
  }
976
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
+
977
1113
  // src/internal/resolver/types.ts
978
1114
  var separatorValues = /* @__PURE__ */ new WeakSet();
979
1115
  function isSubst(v) {
@@ -1075,6 +1211,7 @@ import * as nodePath from "path";
1075
1211
  // src/internal/properties/properties.ts
1076
1212
  function parseProperties(input) {
1077
1213
  const root = /* @__PURE__ */ Object.create(null);
1214
+ const pairs = [];
1078
1215
  for (const line of input.split("\n")) {
1079
1216
  const trimmed = line.trim();
1080
1217
  if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("!")) continue;
@@ -1083,6 +1220,10 @@ function parseProperties(input) {
1083
1220
  const key = trimmed.slice(0, sepIdx).trim();
1084
1221
  const value = trimmed.slice(sepIdx + 1).trim();
1085
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) {
1086
1227
  setNested(root, key.split("."), value);
1087
1228
  }
1088
1229
  return root;
@@ -1106,6 +1247,7 @@ function setNested(obj, segments, value) {
1106
1247
  }
1107
1248
  const last = segments[segments.length - 1];
1108
1249
  if (last === void 0 || DANGEROUS_KEYS.has(last)) return;
1250
+ if (typeof current[last] === "object" && current[last] !== null) return;
1109
1251
  current[last] = value;
1110
1252
  }
1111
1253
  function propertiesToHoconValue(input) {
@@ -1219,7 +1361,9 @@ var IncludeLoader = class {
1219
1361
  if (candidate.endsWith(".properties")) {
1220
1362
  return hoconValueToResObj(propertiesToHoconValue(content));
1221
1363
  }
1222
- const ast = parseTokens(tokenize(content));
1364
+ const tokens = tokenize(content);
1365
+ assertNonEmptyDocument(tokens, candidate);
1366
+ const ast = parseTokens(tokens);
1223
1367
  return this.onBuildResObj(ast, {
1224
1368
  env,
1225
1369
  baseDir: nodePath.dirname(candidate),
@@ -1243,7 +1387,9 @@ var IncludeLoader = class {
1243
1387
  if (candidate.endsWith(".properties")) {
1244
1388
  return hoconValueToResObj(propertiesToHoconValue(content));
1245
1389
  }
1246
- const ast = parseTokens(tokenize(content));
1390
+ const tokens = tokenize(content);
1391
+ assertNonEmptyDocument(tokens, candidate);
1392
+ const ast = parseTokens(tokens);
1247
1393
  return this.onBuildResObjAsync(ast, {
1248
1394
  env,
1249
1395
  baseDir: nodePath.dirname(candidate),
@@ -1380,9 +1526,9 @@ var StructureBuilder = class _StructureBuilder {
1380
1526
  return inner;
1381
1527
  }
1382
1528
  case "subst":
1383
- return { _kind: "subst-placeholder", segments: ast.segments, 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 };
1384
1530
  case "concat":
1385
- 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 };
1386
1532
  case "include":
1387
1533
  return { kind: "scalar", raw: "null", valueType: "null" };
1388
1534
  }
@@ -1404,13 +1550,13 @@ var StructureBuilder = class _StructureBuilder {
1404
1550
  case "object":
1405
1551
  return await this.buildAsync(ast, pathPrefix);
1406
1552
  case "subst":
1407
- return { _kind: "subst-placeholder", segments: ast.segments, 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 };
1408
1554
  case "concat": {
1409
1555
  const nodes = [];
1410
1556
  for (const n of ast.nodes) {
1411
1557
  nodes.push(await this.astToResolverValueAsync(n, pathPrefix));
1412
1558
  }
1413
- return { _kind: "concat-placeholder", nodes };
1559
+ return { _kind: "concat-placeholder", nodes, line: ast.pos.line, col: ast.pos.col };
1414
1560
  }
1415
1561
  case "include":
1416
1562
  return { kind: "scalar", raw: "null", valueType: "null" };
@@ -1463,6 +1609,19 @@ var SubstitutionResolver = class {
1463
1609
  this.opts = opts;
1464
1610
  this.resolving = /* @__PURE__ */ new Set();
1465
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();
1466
1625
  }
1467
1626
  resolve() {
1468
1627
  return this.resolveResObj(this.root);
@@ -1501,7 +1660,14 @@ var SubstitutionResolver = class {
1501
1660
  }
1502
1661
  resolveVal(v, scope) {
1503
1662
  if (isSubst(v)) return this.resolveSubst(v, scope);
1504
- 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
+ }
1505
1671
  if (isAppend(v)) return this.resolveAppend(v, scope);
1506
1672
  if (isResObj(v)) return this.resolveResObj(v);
1507
1673
  const hv = v;
@@ -1515,15 +1681,12 @@ var SubstitutionResolver = class {
1515
1681
  }
1516
1682
  return hv;
1517
1683
  }
1518
- segmentsEqual(a, b) {
1519
- return a.length === b.length && a.every((seg, i) => seg.text === b[i]?.text);
1520
- }
1521
1684
  resolveSubst(s, scope) {
1522
- const key = segmentsToKey(s.segments);
1685
+ const key = s.listSuffix ? `${segmentsToKey(s.segments)}[]` : segmentsToKey(s.segments);
1523
1686
  if (this.cache.has(key)) return this.cache.get(key);
1524
1687
  if (this.resolving.has(key)) {
1525
1688
  let prior;
1526
- if (s.prefixLen > 0) {
1689
+ if (s.segments.length > 1) {
1527
1690
  const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1528
1691
  const parentScope = lookupResObj(
1529
1692
  this.root,
@@ -1555,29 +1718,31 @@ var SubstitutionResolver = class {
1555
1718
  try {
1556
1719
  const found = lookupPath(this.root, s.segments);
1557
1720
  if (found !== void 0) {
1558
- if (isSubst(found) || isConcat(found)) {
1559
- const isSelfRef = isSubst(found) ? this.segmentsEqual(found.segments, s.segments) : isConcat(found) && found.nodes.some(
1560
- (n) => isSubst(n) && this.segmentsEqual(n.segments, s.segments)
1561
- );
1562
- if (isSelfRef) {
1563
- let prior;
1564
- if (s.prefixLen > 0) {
1565
- const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1566
- const parentScope = lookupResObj(
1567
- this.root,
1568
- s.segments.slice(0, s.segments.length - 1)
1569
- );
1570
- prior = parentScope?.priorValues.get(leafSeg);
1571
- } else {
1572
- const rootSeg = s.segments[0]?.text ?? "";
1573
- prior = scope.priorValues.get(rootSeg) ?? this.root.priorValues.get(rootSeg);
1574
- }
1575
- if (prior !== void 0) {
1576
- const result2 = this.resolveVal(prior, scope);
1577
- if (result2 !== void 0) this.cache.set(key, result2);
1578
- return result2;
1579
- }
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);
1580
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;
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
+ );
1581
1746
  }
1582
1747
  let result = this.resolveVal(found, scope);
1583
1748
  const effectiveLen = s.segments.length - s.prefixLen;
@@ -1606,6 +1771,21 @@ var SubstitutionResolver = class {
1606
1771
  if (result !== void 0) this.cache.set(key, result);
1607
1772
  return result;
1608
1773
  }
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
+ }
1609
1789
  const envKey = s.segments.map((seg) => seg.text).join(".");
1610
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);
1611
1791
  if (envVal !== void 0) {
@@ -1624,7 +1804,42 @@ var SubstitutionResolver = class {
1624
1804
  this.resolving.delete(key);
1625
1805
  }
1626
1806
  }
1627
- 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) {
1628
1843
  const resolved = nodes.map((n) => this.resolveVal(n, scope)).filter((v) => v !== void 0);
1629
1844
  if (resolved.length === 0) return { kind: "scalar", raw: "null", valueType: "null" };
1630
1845
  if (resolved.length === 1) return resolved[0];
@@ -1642,27 +1857,45 @@ var SubstitutionResolver = class {
1642
1857
  if (converted !== null) {
1643
1858
  return { kind: "array", items: [...left.items, ...converted] };
1644
1859
  }
1645
- return { kind: "array", items: [...left.items, right] };
1860
+ throw new ResolveError(
1861
+ "cannot concatenate array with object: value concatenation requires same-kind operands (S10.4)",
1862
+ "",
1863
+ line,
1864
+ col
1865
+ );
1646
1866
  }
1647
1867
  if (left.kind === "object" && right.kind === "array") {
1648
1868
  const converted = numericObjectToArray(left);
1649
1869
  if (converted !== null) {
1650
1870
  return { kind: "array", items: [...converted, ...right.items] };
1651
1871
  }
1652
- return { kind: "array", items: [left, ...right.items] };
1872
+ throw new ResolveError(
1873
+ "cannot concatenate object with array: value concatenation requires same-kind operands (S10.4)",
1874
+ "",
1875
+ line,
1876
+ col
1877
+ );
1653
1878
  }
1654
1879
  if (left.kind === "array" && right.kind === "array") {
1655
1880
  return { kind: "array", items: [...left.items, ...right.items] };
1656
1881
  }
1657
- if (left.kind === "array") {
1658
- return { kind: "array", items: [...left.items, right] };
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
+ );
1659
1889
  }
1660
- if (right.kind === "array") {
1661
- return { kind: "array", items: [left, ...right.items] };
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
+ );
1662
1897
  }
1663
- const leftStr = left.kind === "scalar" ? left.raw : JSON.stringify(left);
1664
- const rightStr = right.kind === "scalar" ? right.raw : JSON.stringify(right);
1665
- return { kind: "scalar", raw: leftStr + rightStr, valueType: "string" };
1898
+ return { kind: "scalar", raw: left.raw + right.raw, valueType: "string" };
1666
1899
  };
1667
1900
  const [head, ...tail] = nonSep;
1668
1901
  const folded = tail.reduce(joinPair, head);
@@ -1707,6 +1940,7 @@ async function defaultReadFile(filePath) {
1707
1940
  }
1708
1941
  function buildResolveContext(input, opts) {
1709
1942
  const tokens = tokenize(input);
1943
+ assertNonEmptyDocument(tokens, "input");
1710
1944
  const ast = parseTokens(tokens);
1711
1945
  const resolveOpts = {
1712
1946
  env: getEnv(opts),