@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/zod.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
  }
@@ -292,14 +350,6 @@ var Lexer = class {
292
350
  continue;
293
351
  }
294
352
  if (isUnquotedStart(ch)) {
295
- if (ch === "-" && !isDecimalDigit(this.peek(1))) {
296
- const after = this.peek(1) === "" ? "EOF" : JSON.stringify(this.peek(1));
297
- throw new ParseError(
298
- `unquoted string cannot begin with '-' unless followed by a digit (got '-' then ${after}, HOCON.md L270-276)`,
299
- sl,
300
- sc
301
- );
302
- }
303
353
  let value = "";
304
354
  while (this.pos < this.input.length && isUnquotedContinue(this.peek(), () => this.peek(1))) {
305
355
  value += this.advance();
@@ -403,6 +453,10 @@ var Lexer = class {
403
453
  /**
404
454
  * Parse the body of a `${...}` substitution (called after `${` has been consumed).
405
455
  * Implements Appendix A state machine from the cross-language spec.
456
+ *
457
+ * S13c extension: if the segment-collection loop encounters `[`, it delegates to
458
+ * `parseListSuffix()` which consumes the literal `[]` and sets `listSuffix=true`.
459
+ * ASCII space/tab before `[` is buffered in `pendingWs` and discarded (E7).
406
460
  */
407
461
  parseSubstBody(startLine, startCol) {
408
462
  let optional = false;
@@ -417,6 +471,7 @@ var Lexer = class {
417
471
  let pendingWs = "";
418
472
  const segments = [];
419
473
  let lastDot = null;
474
+ let listSuffix = false;
420
475
  while (true) {
421
476
  if (this.pos >= this.input.length) {
422
477
  throw new ParseError("unterminated substitution", startLine, startCol);
@@ -426,6 +481,23 @@ var Lexer = class {
426
481
  this.advance();
427
482
  pendingWs = "";
428
483
  break;
484
+ } else if (ch === "[") {
485
+ if (!curStarted) {
486
+ throw new ParseError("empty segment before '[]' suffix in substitution path", startLine, this.col);
487
+ }
488
+ for (const w of pendingWs) {
489
+ if (w !== " " && w !== " ") {
490
+ throw new ParseError(
491
+ `only ASCII space or tab allowed between substitution path and '[]' suffix (got ${JSON.stringify(w)}, HOCON extra-spec E7)`,
492
+ startLine,
493
+ this.col
494
+ );
495
+ }
496
+ }
497
+ segments.push({ text: curText, line: curLine, col: curCol });
498
+ pendingWs = "";
499
+ listSuffix = this.parseListSuffix(startLine);
500
+ break;
429
501
  } else if (ch === '"') {
430
502
  const qLine = startLine;
431
503
  const qCol = this.col;
@@ -485,15 +557,47 @@ var Lexer = class {
485
557
  throw new ParseError(`unexpected character in substitution path: ${JSON.stringify(ch)}`, startLine, this.col);
486
558
  }
487
559
  }
488
- if (curStarted) {
489
- segments.push({ text: curText, line: curLine, col: curCol });
490
- } else if (segments.length === 0) {
491
- throw new ParseError("empty substitution path", startLine, startCol);
492
- } else {
493
- const [errLine, errCol] = lastDot ?? [startLine, startCol];
494
- throw new ParseError("empty segment in path", errLine, errCol);
560
+ if (!listSuffix) {
561
+ if (curStarted) {
562
+ segments.push({ text: curText, line: curLine, col: curCol });
563
+ } else if (segments.length === 0) {
564
+ throw new ParseError("empty substitution path", startLine, startCol);
565
+ } else {
566
+ const [errLine, errCol] = lastDot ?? [startLine, startCol];
567
+ throw new ParseError("empty segment in path", errLine, errCol);
568
+ }
495
569
  }
496
- return { segments, optional };
570
+ return { segments, optional, listSuffix };
571
+ }
572
+ /**
573
+ * Consume the literal `[]` suffix inside a substitution body.
574
+ * Called after the `[` has been detected (but not yet consumed).
575
+ * Requires the next character to be `[`, then exactly `]`, then `}`.
576
+ * Whitespace inside `[]` is not permitted (per spec Decision §1).
577
+ * Returns true (always — only called when listSuffix path is taken).
578
+ */
579
+ parseListSuffix(startLine) {
580
+ this.advance();
581
+ const afterBracket = this.peek();
582
+ if (afterBracket !== "]") {
583
+ const desc = afterBracket === "" ? "EOF" : JSON.stringify(afterBracket);
584
+ throw new ParseError(
585
+ `expected ']' after '[' in substitution list suffix (got ${desc})`,
586
+ startLine,
587
+ this.col
588
+ );
589
+ }
590
+ this.advance();
591
+ if (this.peek() !== "}") {
592
+ const desc = this.peek() === "" ? "EOF" : JSON.stringify(this.peek());
593
+ throw new ParseError(
594
+ `expected '}' after '[]' in substitution list suffix (got ${desc})`,
595
+ startLine,
596
+ this.col
597
+ );
598
+ }
599
+ this.advance();
600
+ return true;
497
601
  }
498
602
  };
499
603
  function tokenize(input) {
@@ -581,6 +685,14 @@ var Parser = class {
581
685
  const t = this.peek();
582
686
  if (t.kind === "eof" || t.kind === "rbrace") break;
583
687
  if (t.kind === "unquoted" && t.value === "include") {
688
+ const next = this.peek(1);
689
+ if (next.kind === "equals" || next.kind === "colon" || next.kind === "plus_equals" || next.kind === "lbrace") {
690
+ throw new ParseError(
691
+ `'include' is reserved at the start of a key path expression; use "include" (quoted) to use it as a key`,
692
+ t.line,
693
+ t.col
694
+ );
695
+ }
584
696
  this.advance();
585
697
  fields.push(this.parseInclude());
586
698
  this.skip("newline");
@@ -589,7 +701,14 @@ var Parser = class {
589
701
  continue;
590
702
  }
591
703
  const keyPos = this.currentPos();
592
- const key = this.parseKey();
704
+ const { segments: key, firstWasQuoted } = this.parseKey();
705
+ if (key[0] === "include" && !firstWasQuoted) {
706
+ throw new ParseError(
707
+ `'include' is reserved at the start of a key path expression; use "include".foo (quoted) or rename the key`,
708
+ keyPos.line,
709
+ keyPos.col
710
+ );
711
+ }
593
712
  this.skip("newline");
594
713
  let append = false;
595
714
  const sep = this.peek();
@@ -619,10 +738,12 @@ var Parser = class {
619
738
  parseKey() {
620
739
  const segments = [];
621
740
  let trailingDot = false;
741
+ let firstWasQuoted = false;
622
742
  while (true) {
623
743
  const t = this.peek();
624
744
  if (t.kind === "string") {
625
745
  this.advance();
746
+ if (segments.length === 0) firstWasQuoted = true;
626
747
  segments.push(t.value);
627
748
  trailingDot = false;
628
749
  } else if (t.kind === "unquoted") {
@@ -659,7 +780,7 @@ var Parser = class {
659
780
  }
660
781
  break;
661
782
  }
662
- return segments;
783
+ return { segments, firstWasQuoted };
663
784
  }
664
785
  parseInclude() {
665
786
  const p = this.currentPos();
@@ -738,7 +859,7 @@ var Parser = class {
738
859
  throw new ParseError(`internal: subst token missing payload`, t.line, t.col);
739
860
  }
740
861
  const payload = t.subst;
741
- node = { kind: "subst", segments: payload.segments, optional: payload.optional, pos: { line: t.line, col: t.col } };
862
+ node = { kind: "subst", segments: payload.segments, optional: payload.optional, listSuffix: payload.listSuffix, pos: { line: t.line, col: t.col } };
742
863
  } else if (t.kind === "string" || t.kind === "triple_string") {
743
864
  this.advance();
744
865
  node = { kind: "scalar", raw: t.value, valueType: "string", pos: { line: t.line, col: t.col } };
@@ -789,6 +910,20 @@ function parseTokens(tokens) {
789
910
  return new Parser(tokens).parse();
790
911
  }
791
912
 
913
+ // src/internal/parser/empty-check.ts
914
+ function hasContentTokens(tokens) {
915
+ return tokens.some((t) => t.kind !== "eof" && t.kind !== "newline");
916
+ }
917
+ function assertNonEmptyDocument(tokens, sourceDescriptor) {
918
+ if (!hasContentTokens(tokens)) {
919
+ throw new ParseError(
920
+ `empty file is not a valid HOCON document (HOCON.md L130): ${sourceDescriptor}`,
921
+ 1,
922
+ 1
923
+ );
924
+ }
925
+ }
926
+
792
927
  // src/internal/resolver/types.ts
793
928
  var separatorValues = /* @__PURE__ */ new WeakSet();
794
929
  function isSubst(v) {
@@ -890,6 +1025,7 @@ import * as nodePath from "path";
890
1025
  // src/internal/properties/properties.ts
891
1026
  function parseProperties(input) {
892
1027
  const root = /* @__PURE__ */ Object.create(null);
1028
+ const pairs = [];
893
1029
  for (const line of input.split("\n")) {
894
1030
  const trimmed = line.trim();
895
1031
  if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("!")) continue;
@@ -898,6 +1034,10 @@ function parseProperties(input) {
898
1034
  const key = trimmed.slice(0, sepIdx).trim();
899
1035
  const value = trimmed.slice(sepIdx + 1).trim();
900
1036
  if (key === "") continue;
1037
+ pairs.push([key, value]);
1038
+ }
1039
+ pairs.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1040
+ for (const [key, value] of pairs) {
901
1041
  setNested(root, key.split("."), value);
902
1042
  }
903
1043
  return root;
@@ -921,6 +1061,7 @@ function setNested(obj, segments, value) {
921
1061
  }
922
1062
  const last = segments[segments.length - 1];
923
1063
  if (last === void 0 || DANGEROUS_KEYS.has(last)) return;
1064
+ if (typeof current[last] === "object" && current[last] !== null) return;
924
1065
  current[last] = value;
925
1066
  }
926
1067
  function propertiesToHoconValue(input) {
@@ -1034,7 +1175,9 @@ var IncludeLoader = class {
1034
1175
  if (candidate.endsWith(".properties")) {
1035
1176
  return hoconValueToResObj(propertiesToHoconValue(content));
1036
1177
  }
1037
- const ast = parseTokens(tokenize(content));
1178
+ const tokens = tokenize(content);
1179
+ assertNonEmptyDocument(tokens, candidate);
1180
+ const ast = parseTokens(tokens);
1038
1181
  return this.onBuildResObj(ast, {
1039
1182
  env,
1040
1183
  baseDir: nodePath.dirname(candidate),
@@ -1058,7 +1201,9 @@ var IncludeLoader = class {
1058
1201
  if (candidate.endsWith(".properties")) {
1059
1202
  return hoconValueToResObj(propertiesToHoconValue(content));
1060
1203
  }
1061
- const ast = parseTokens(tokenize(content));
1204
+ const tokens = tokenize(content);
1205
+ assertNonEmptyDocument(tokens, candidate);
1206
+ const ast = parseTokens(tokens);
1062
1207
  return this.onBuildResObjAsync(ast, {
1063
1208
  env,
1064
1209
  baseDir: nodePath.dirname(candidate),
@@ -1195,9 +1340,9 @@ var StructureBuilder = class _StructureBuilder {
1195
1340
  return inner;
1196
1341
  }
1197
1342
  case "subst":
1198
- return { _kind: "subst-placeholder", segments: ast.segments, optional: ast.optional, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1343
+ return { _kind: "subst-placeholder", segments: ast.segments, optional: ast.optional, listSuffix: ast.listSuffix, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1199
1344
  case "concat":
1200
- return { _kind: "concat-placeholder", nodes: ast.nodes.map((n) => this.astToResolverValue(n, pathPrefix)) };
1345
+ return { _kind: "concat-placeholder", nodes: ast.nodes.map((n) => this.astToResolverValue(n, pathPrefix)), line: ast.pos.line, col: ast.pos.col };
1201
1346
  case "include":
1202
1347
  return { kind: "scalar", raw: "null", valueType: "null" };
1203
1348
  }
@@ -1219,13 +1364,13 @@ var StructureBuilder = class _StructureBuilder {
1219
1364
  case "object":
1220
1365
  return await this.buildAsync(ast, pathPrefix);
1221
1366
  case "subst":
1222
- return { _kind: "subst-placeholder", segments: ast.segments, optional: ast.optional, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1367
+ return { _kind: "subst-placeholder", segments: ast.segments, optional: ast.optional, listSuffix: ast.listSuffix, line: ast.pos.line, col: ast.pos.col, prefixLen: 0 };
1223
1368
  case "concat": {
1224
1369
  const nodes = [];
1225
1370
  for (const n of ast.nodes) {
1226
1371
  nodes.push(await this.astToResolverValueAsync(n, pathPrefix));
1227
1372
  }
1228
- return { _kind: "concat-placeholder", nodes };
1373
+ return { _kind: "concat-placeholder", nodes, line: ast.pos.line, col: ast.pos.col };
1229
1374
  }
1230
1375
  case "include":
1231
1376
  return { kind: "scalar", raw: "null", valueType: "null" };
@@ -1295,6 +1440,19 @@ var SubstitutionResolver = class {
1295
1440
  this.opts = opts;
1296
1441
  this.resolving = /* @__PURE__ */ new Set();
1297
1442
  this.cache = /* @__PURE__ */ new Map();
1443
+ // Tracks ConcatPlaceholder nodes currently being iterated by resolveConcat.
1444
+ // Used by isSelfRef detection: fires only when found === a concat currently
1445
+ // mid-iteration (i.e. we are inside found's own resolution), preventing the
1446
+ // "foofoo" double-count without misfiring when an external field looks up the
1447
+ // same concat via a substitution (multi-reviewer convergence fix).
1448
+ //
1449
+ // Spec deviation: the S13a.13 spec ★1 decision #1 specified path-equality
1450
+ // preservation for self-ref detection. Round-2 multi-agent-review surfaced a
1451
+ // false-positive on external lookups (`a = ${?a}foo; b = ${a}`), so the
1452
+ // criterion was tightened to node-membership-in-iterating-set — strictly
1453
+ // narrower than path-equality. Spec amendment deferred to a follow-up
1454
+ // xx.hocon PR (see Phase 6 #3f close-out notes).
1455
+ this.resolvingConcats = /* @__PURE__ */ new WeakSet();
1298
1456
  }
1299
1457
  resolve() {
1300
1458
  return this.resolveResObj(this.root);
@@ -1333,7 +1491,14 @@ var SubstitutionResolver = class {
1333
1491
  }
1334
1492
  resolveVal(v, scope) {
1335
1493
  if (isSubst(v)) return this.resolveSubst(v, scope);
1336
- if (isConcat(v)) return this.resolveConcat(v.nodes, scope);
1494
+ if (isConcat(v)) {
1495
+ this.resolvingConcats.add(v);
1496
+ try {
1497
+ return this.resolveConcat(v.nodes, scope, v.line, v.col);
1498
+ } finally {
1499
+ this.resolvingConcats.delete(v);
1500
+ }
1501
+ }
1337
1502
  if (isAppend(v)) return this.resolveAppend(v, scope);
1338
1503
  if (isResObj(v)) return this.resolveResObj(v);
1339
1504
  const hv = v;
@@ -1347,15 +1512,12 @@ var SubstitutionResolver = class {
1347
1512
  }
1348
1513
  return hv;
1349
1514
  }
1350
- segmentsEqual(a, b) {
1351
- return a.length === b.length && a.every((seg, i) => seg.text === b[i]?.text);
1352
- }
1353
1515
  resolveSubst(s, scope) {
1354
- const key = segmentsToKey(s.segments);
1516
+ const key = s.listSuffix ? `${segmentsToKey(s.segments)}[]` : segmentsToKey(s.segments);
1355
1517
  if (this.cache.has(key)) return this.cache.get(key);
1356
1518
  if (this.resolving.has(key)) {
1357
1519
  let prior;
1358
- if (s.prefixLen > 0) {
1520
+ if (s.segments.length > 1) {
1359
1521
  const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1360
1522
  const parentScope = lookupResObj(
1361
1523
  this.root,
@@ -1387,29 +1549,31 @@ var SubstitutionResolver = class {
1387
1549
  try {
1388
1550
  const found = lookupPath(this.root, s.segments);
1389
1551
  if (found !== void 0) {
1390
- if (isSubst(found) || isConcat(found)) {
1391
- const isSelfRef = isSubst(found) ? this.segmentsEqual(found.segments, s.segments) : isConcat(found) && found.nodes.some(
1392
- (n) => isSubst(n) && this.segmentsEqual(n.segments, s.segments)
1393
- );
1394
- if (isSelfRef) {
1395
- let prior;
1396
- if (s.prefixLen > 0) {
1397
- const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1398
- const parentScope = lookupResObj(
1399
- this.root,
1400
- s.segments.slice(0, s.segments.length - 1)
1401
- );
1402
- prior = parentScope?.priorValues.get(leafSeg);
1403
- } else {
1404
- const rootSeg = s.segments[0]?.text ?? "";
1405
- prior = scope.priorValues.get(rootSeg) ?? this.root.priorValues.get(rootSeg);
1406
- }
1407
- if (prior !== void 0) {
1408
- const result2 = this.resolveVal(prior, scope);
1409
- if (result2 !== void 0) this.cache.set(key, result2);
1410
- return result2;
1411
- }
1552
+ if (isConcat(found) && this.resolvingConcats.has(found)) {
1553
+ let prior;
1554
+ if (s.segments.length > 1) {
1555
+ const leafSeg = s.segments[s.segments.length - 1]?.text ?? "";
1556
+ const parentScope = lookupResObj(
1557
+ this.root,
1558
+ s.segments.slice(0, s.segments.length - 1)
1559
+ );
1560
+ prior = parentScope?.priorValues.get(leafSeg);
1561
+ } else {
1562
+ const rootSeg = s.segments[0]?.text ?? "";
1563
+ prior = scope.priorValues.get(rootSeg) ?? this.root.priorValues.get(rootSeg);
1412
1564
  }
1565
+ if (prior !== void 0) {
1566
+ const result2 = this.resolveVal(prior, scope);
1567
+ if (result2 !== void 0) this.cache.set(key, result2);
1568
+ return result2;
1569
+ }
1570
+ if (s.optional) return void 0;
1571
+ throw new ResolveError(
1572
+ `could not resolve substitution: \${${key}}`,
1573
+ key,
1574
+ s.line,
1575
+ s.col
1576
+ );
1413
1577
  }
1414
1578
  let result = this.resolveVal(found, scope);
1415
1579
  const effectiveLen = s.segments.length - s.prefixLen;
@@ -1438,6 +1602,21 @@ var SubstitutionResolver = class {
1438
1602
  if (result !== void 0) this.cache.set(key, result);
1439
1603
  return result;
1440
1604
  }
1605
+ if (s.listSuffix) {
1606
+ const result = this.resolveEnvList(s);
1607
+ if (result !== void 0) {
1608
+ this.cache.set(key, result);
1609
+ return result;
1610
+ }
1611
+ if (s.optional) return void 0;
1612
+ const envBase = s.segments.map((seg) => seg.text).join(".");
1613
+ throw new ResolveError(
1614
+ `could not resolve substitution: \${${key}} (no environment variables ${envBase}_0, ${envBase}_1, \u2026 set)`,
1615
+ key,
1616
+ s.line,
1617
+ s.col
1618
+ );
1619
+ }
1441
1620
  const envKey = s.segments.map((seg) => seg.text).join(".");
1442
1621
  const envVal = this.opts.env[envKey] ?? (s.prefixLen > 0 ? this.opts.env[s.segments.slice(s.prefixLen).map((seg) => seg.text).join(".")] : void 0);
1443
1622
  if (envVal !== void 0) {
@@ -1456,7 +1635,42 @@ var SubstitutionResolver = class {
1456
1635
  this.resolving.delete(key);
1457
1636
  }
1458
1637
  }
1459
- resolveConcat(nodes, scope) {
1638
+ /**
1639
+ * S13c: scan environment for NAME_0, NAME_1, … and return an Array<Scalar(String)>.
1640
+ *
1641
+ * Candidate bases follow the same fully-qualified→bare order used by the scalar
1642
+ * env fallback. The first base whose _0 key is present wins entirely — no
1643
+ * cross-base merging (spec §Relativized fallback precedence rules).
1644
+ *
1645
+ * Returns the HoconValue Array if any candidate base has _0, or undefined if
1646
+ * none do (caller applies optional/required handling).
1647
+ */
1648
+ resolveEnvList(s) {
1649
+ const bases = this.candidateBases(s);
1650
+ for (const base of bases) {
1651
+ const key0 = base + "_0";
1652
+ if (!(key0 in this.opts.env)) continue;
1653
+ const items = [];
1654
+ for (let i = 0; ; i++) {
1655
+ const k = base + "_" + i;
1656
+ if (!(k in this.opts.env)) break;
1657
+ items.push({ kind: "scalar", raw: this.opts.env[k], valueType: "string" });
1658
+ }
1659
+ return { kind: "array", items };
1660
+ }
1661
+ return void 0;
1662
+ }
1663
+ /**
1664
+ * Return the candidate base names for env-var-list lookup, in precedence order.
1665
+ * Mirrors the fully-qualified→bare fallback used by the scalar env path.
1666
+ */
1667
+ candidateBases(s) {
1668
+ const full = s.segments.map((seg) => seg.text).join(".");
1669
+ if (s.prefixLen === 0) return [full];
1670
+ const bare = s.segments.slice(s.prefixLen).map((seg) => seg.text).join(".");
1671
+ return [full, bare];
1672
+ }
1673
+ resolveConcat(nodes, scope, line = 0, col = 0) {
1460
1674
  const resolved = nodes.map((n) => this.resolveVal(n, scope)).filter((v) => v !== void 0);
1461
1675
  if (resolved.length === 0) return { kind: "scalar", raw: "null", valueType: "null" };
1462
1676
  if (resolved.length === 1) return resolved[0];
@@ -1474,27 +1688,45 @@ var SubstitutionResolver = class {
1474
1688
  if (converted !== null) {
1475
1689
  return { kind: "array", items: [...left.items, ...converted] };
1476
1690
  }
1477
- return { kind: "array", items: [...left.items, right] };
1691
+ throw new ResolveError(
1692
+ "cannot concatenate array with object: value concatenation requires same-kind operands (S10.4)",
1693
+ "",
1694
+ line,
1695
+ col
1696
+ );
1478
1697
  }
1479
1698
  if (left.kind === "object" && right.kind === "array") {
1480
1699
  const converted = numericObjectToArray(left);
1481
1700
  if (converted !== null) {
1482
1701
  return { kind: "array", items: [...converted, ...right.items] };
1483
1702
  }
1484
- return { kind: "array", items: [left, ...right.items] };
1703
+ throw new ResolveError(
1704
+ "cannot concatenate object with array: value concatenation requires same-kind operands (S10.4)",
1705
+ "",
1706
+ line,
1707
+ col
1708
+ );
1485
1709
  }
1486
1710
  if (left.kind === "array" && right.kind === "array") {
1487
1711
  return { kind: "array", items: [...left.items, ...right.items] };
1488
1712
  }
1489
- if (left.kind === "array") {
1490
- return { kind: "array", items: [...left.items, right] };
1713
+ if (left.kind === "array" || right.kind === "array") {
1714
+ throw new ResolveError(
1715
+ `cannot concatenate ${left.kind} with ${right.kind}: arrays and objects may not appear in string value concatenation (S10.13)`,
1716
+ "",
1717
+ line,
1718
+ col
1719
+ );
1491
1720
  }
1492
- if (right.kind === "array") {
1493
- return { kind: "array", items: [left, ...right.items] };
1721
+ if (left.kind === "object" || right.kind === "object") {
1722
+ throw new ResolveError(
1723
+ `cannot concatenate ${left.kind} with ${right.kind}: arrays and objects may not appear in string value concatenation (S10.13)`,
1724
+ "",
1725
+ line,
1726
+ col
1727
+ );
1494
1728
  }
1495
- const leftStr = left.kind === "scalar" ? left.raw : JSON.stringify(left);
1496
- const rightStr = right.kind === "scalar" ? right.raw : JSON.stringify(right);
1497
- return { kind: "scalar", raw: leftStr + rightStr, valueType: "string" };
1729
+ return { kind: "scalar", raw: left.raw + right.raw, valueType: "string" };
1498
1730
  };
1499
1731
  const [head, ...tail] = nonSep;
1500
1732
  const folded = tail.reduce(joinPair, head);
@@ -1561,6 +1793,7 @@ var Config = class _Config {
1561
1793
  }
1562
1794
  const result = parseBytes(v.raw, unit);
1563
1795
  if (Number.isNaN(result)) throw new ConfigError(`invalid byte size at ${path2}: ${JSON.stringify(v.raw)}`, path2);
1796
+ if (result < 0) throw new ConfigError(`byte size must be non-negative at ${path2}: ${JSON.stringify(v.raw)}`, path2);
1564
1797
  return result;
1565
1798
  }
1566
1799
  getConfig(path2) {
@@ -1700,6 +1933,7 @@ function defaultReadFileSync(filePath) {
1700
1933
  }
1701
1934
  function buildResolveContext(input, opts) {
1702
1935
  const tokens = tokenize(input);
1936
+ assertNonEmptyDocument(tokens, "input");
1703
1937
  const ast = parseTokens(tokens);
1704
1938
  const resolveOpts = {
1705
1939
  env: getEnv(opts),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/ts.hocon",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Full Lightbend HOCON spec-compliant parser and config library for TypeScript",
5
5
  "keywords": [
6
6
  "hocon",