@o3co/ts.hocon 1.8.0 → 1.9.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/README.md CHANGED
@@ -14,7 +14,7 @@ A [Lightbend HOCON](https://github.com/lightbend/config/blob/main/HOCON.md) pars
14
14
 
15
15
  > **Library stance:** ts.hocon is a HOCON config loader — its purpose is reading `.hocon` config files and providing typed access via the Config API (`getString`, `getNumber`, `getBoolean`, `getDuration`, `getBytes`, `toObject`). It is not a low-level parser API. Internal types like `HoconValue` may change between minor versions.
16
16
  >
17
- > **Cross-language conformance:** This implementation is tested against shared expected-JSON fixtures from [o3co/xx.hocon](https://github.com/o3co/xx.hocon) alongside [go.hocon](https://github.com/o3co/go.hocon) and [rs.hocon](https://github.com/o3co/rs.hocon) to ensure all three implementations meet the same Lightbend HOCON specification.
17
+ > **Cross-language conformance:** This implementation is tested against shared expected-JSON fixtures from [o3co/xx.hocon](https://github.com/o3co/xx.hocon) alongside [go.hocon](https://github.com/o3co/go.hocon), [rs.hocon](https://github.com/o3co/rs.hocon), and [py.hocon](https://github.com/o3co/py.hocon) to ensure all four implementations meet the same Lightbend HOCON specification.
18
18
 
19
19
  ---
20
20
 
@@ -245,7 +245,7 @@ c.getBytes('max-size') // 536870912 (bytes)
245
245
  c.getBytes('max-size', 'MiB') // 512
246
246
  ```
247
247
 
248
- Supported duration units: `ns`, `us`, `ms`, `s`, `m`, `h`, `d` (and long forms like `seconds`, `minutes`).
248
+ Supported duration units: `ns`, `us`, `ms`, `s`, `m`, `h`, `d` (and long forms like `seconds`, `minutes`). Duration unit names are case-sensitive and must be lowercase (HOCON spec). Byte units are more case-tolerant: the canonical forms below plus lowercase aliases (`kb`, `kib`, …), any-case long forms (`megabytes`), and single-letter powers-of-two in both cases (`K`/`k`, per Lightbend).
249
249
  Supported byte units: `B`, `KB`/`KiB`, `MB`/`MiB`, `GB`/`GiB`, `TB`/`TiB` (and long forms like `megabytes`, `mebibytes`).
250
250
 
251
251
  ## Spec Compliance
@@ -386,9 +386,10 @@ const config = parseWithSchema(hoconInput, schema) // fails fast on startup
386
386
  |---------|----------|----------|-------------|
387
387
  | [go.hocon](https://github.com/o3co/go.hocon) | Go | [pkg.go.dev](https://pkg.go.dev/github.com/o3co/go.hocon) | HOCON parser for Go |
388
388
  | [rs.hocon](https://github.com/o3co/rs.hocon) | Rust | [crates.io](https://crates.io/crates/o3co-hocon) | HOCON parser for Rust |
389
+ | [py.hocon](https://github.com/o3co/py.hocon) | Python | [PyPI](https://pypi.org/project/hocon-parser/) | HOCON parser for Python |
389
390
  | [hocon2](https://github.com/o3co/hocon2) | Go | [pkg.go.dev](https://pkg.go.dev/github.com/o3co/hocon2) | HOCON → JSON/YAML/TOML/Properties CLI |
390
391
 
391
- The three parser implementations ([ts.hocon](https://github.com/o3co/ts.hocon), [rs.hocon](https://github.com/o3co/rs.hocon), [go.hocon](https://github.com/o3co/go.hocon)) are all tracked against the same Lightbend HOCON spec — see the [cross-impl roll-up](https://github.com/o3co/xx.hocon/blob/main/docs/compliance-matrix.md) for per-impl conformance rates.
392
+ The four parser implementations ([ts.hocon](https://github.com/o3co/ts.hocon), [rs.hocon](https://github.com/o3co/rs.hocon), [go.hocon](https://github.com/o3co/go.hocon), [py.hocon](https://github.com/o3co/py.hocon)) are all tracked against the same Lightbend HOCON spec — see the [cross-impl roll-up](https://github.com/o3co/xx.hocon/blob/main/docs/compliance-matrix.md) for per-impl conformance rates.
392
393
 
393
394
  ## Known Limitations
394
395
 
@@ -402,7 +403,7 @@ The three parser implementations ([ts.hocon](https://github.com/o3co/ts.hocon),
402
403
 
403
404
  When parsing untrusted HOCON input, be aware of:
404
405
 
405
- - **Path traversal in includes:** `include "../../../etc/passwd"` will resolve relative to `baseDir`. Use a custom `readFileSync`/`readFile` that validates paths if parsing untrusted input.
406
+ - **Path traversal in includes:** a relative `include` path resolves against `baseDir` and can climb out of it with `..` segments to reach sensitive files such as `/etc/passwd`. Use a custom `readFileSync`/`readFile` that validates paths if parsing untrusted input.
406
407
  - **Input size:** The parser has no built-in input size limit. For untrusted input, validate size before calling `parse()`.
407
408
  - **Include depth:** Limited to 50 levels to prevent stack overflow from deep include chains.
408
409
 
package/dist/index.cjs CHANGED
@@ -141,7 +141,7 @@ function parseDuration(value, outputUnit = "ms") {
141
141
  if (i === 0) return NaN;
142
142
  const num = Number(trimmed.slice(0, i));
143
143
  if (Number.isNaN(num)) return NaN;
144
- const unit = trimHoconWs(trimmed.slice(i)).toLowerCase();
144
+ const unit = trimHoconWs(trimmed.slice(i));
145
145
  const divisor = OUTPUT_DURATION_UNITS[outputUnit];
146
146
  if (divisor === void 0) return NaN;
147
147
  if (unit === "") {
@@ -701,20 +701,6 @@ function isFileNotFoundError(e) {
701
701
  var import_node_module = require("module");
702
702
  var nodePath = __toESM(require("path"), 1);
703
703
 
704
- // src/internal/parser/empty-check.ts
705
- function hasContentTokens(tokens) {
706
- return tokens.some((t) => t.kind !== "eof" && t.kind !== "newline");
707
- }
708
- function assertNonEmptyDocument(tokens, sourceDescriptor) {
709
- if (!hasContentTokens(tokens)) {
710
- throw new ParseError(
711
- `empty file is not a valid HOCON document (HOCON.md L130): ${sourceDescriptor}`,
712
- 1,
713
- 1
714
- );
715
- }
716
- }
717
-
718
704
  // src/internal/lexer/lexer.ts
719
705
  function isHoconWhitespace(ch) {
720
706
  const cp = ch.codePointAt(0) ?? -1;
@@ -1160,6 +1146,16 @@ var Parser = class {
1160
1146
  }
1161
1147
  return { kind: "object", fields: allFields, pos: first.pos };
1162
1148
  }
1149
+ if (t.kind === "lbracket") {
1150
+ this.advance();
1151
+ const arr = this.parseArray();
1152
+ this.skip("newline");
1153
+ const remaining = this.peek();
1154
+ if (remaining.kind !== "eof") {
1155
+ throw new ParseError(`Unexpected token '${remaining.value}' after root array`, remaining.line, remaining.col);
1156
+ }
1157
+ return { ...arr, pos: { line: t.line, col: t.col } };
1158
+ }
1163
1159
  return this.parseObject(false);
1164
1160
  }
1165
1161
  peek(offset = 0) {
@@ -1599,6 +1595,16 @@ var _require = (0, import_node_module.createRequire)(import_meta.url);
1599
1595
  function isPnpPath(p) {
1600
1596
  return p.includes(".yarn/cache") || p.includes(".pnp.cjs") || p.includes(".pnp.loader");
1601
1597
  }
1598
+ function assertObjectRootedInclude(ast, sourcePath) {
1599
+ if (ast.kind === "array") {
1600
+ throw new ResolveError(
1601
+ `included file has array at file root \u2014 an included file must contain an object, not an array (HOCON.md L993-994): ${sourcePath}`,
1602
+ sourcePath,
1603
+ ast.pos.line,
1604
+ ast.pos.col
1605
+ );
1606
+ }
1607
+ }
1602
1608
  function validatePackageFile(file, identifier) {
1603
1609
  if (file.length === 0) {
1604
1610
  throw new ParseError(`include package("${identifier}", ...): file argument must be non-empty`, 0, 0);
@@ -1778,10 +1784,9 @@ var IncludeLoader = class {
1778
1784
  const resolver = packageResolver ?? ((id, f, includingFile, bDir) => defaultPackageResolver(id, f, includingFile, bDir, resolveFrom));
1779
1785
  const resolvedPath = resolver(identifier, file, void 0, baseDir);
1780
1786
  const content = readFileSync2(resolvedPath);
1781
- if (content.length === 0) return makeResObj();
1782
1787
  const tokens = tokenize(content);
1783
- assertNonEmptyDocument(tokens, resolvedPath);
1784
1788
  const ast = parseTokens(tokens);
1789
+ assertObjectRootedInclude(ast, resolvedPath);
1785
1790
  return this.onBuildResObj(ast, {
1786
1791
  ...this.opts,
1787
1792
  baseDir: nodePath.dirname(resolvedPath),
@@ -1811,10 +1816,9 @@ var IncludeLoader = class {
1811
1816
  const resolvedPath = resolver(identifier, file, void 0, baseDir);
1812
1817
  const read = readFile ? async (p) => readFile(p) : async (p) => readFileSync2(p);
1813
1818
  const content = await read(resolvedPath);
1814
- if (content.length === 0) return makeResObj();
1815
1819
  const tokens = tokenize(content);
1816
- assertNonEmptyDocument(tokens, resolvedPath);
1817
1820
  const ast = parseTokens(tokens);
1821
+ assertObjectRootedInclude(ast, resolvedPath);
1818
1822
  return this.onBuildResObjAsync(ast, {
1819
1823
  ...this.opts,
1820
1824
  baseDir: nodePath.dirname(resolvedPath),
@@ -1837,10 +1841,8 @@ var IncludeLoader = class {
1837
1841
  return hoconValueToResObj(propertiesToHoconValue(content));
1838
1842
  }
1839
1843
  const tokens = tokenize(content);
1840
- if (!hasContentTokens(tokens)) {
1841
- return makeResObj();
1842
- }
1843
1844
  const ast = parseTokens(tokens);
1845
+ assertObjectRootedInclude(ast, candidate);
1844
1846
  return this.onBuildResObj(ast, {
1845
1847
  ...this.opts,
1846
1848
  baseDir: nodePath.dirname(candidate),
@@ -1864,10 +1866,8 @@ var IncludeLoader = class {
1864
1866
  return hoconValueToResObj(propertiesToHoconValue(content));
1865
1867
  }
1866
1868
  const tokens = tokenize(content);
1867
- if (!hasContentTokens(tokens)) {
1868
- return makeResObj();
1869
- }
1870
1869
  const ast = parseTokens(tokens);
1870
+ assertObjectRootedInclude(ast, candidate);
1871
1871
  return this.onBuildResObjAsync(ast, {
1872
1872
  ...this.opts,
1873
1873
  baseDir: nodePath.dirname(candidate),
@@ -3059,8 +3059,14 @@ async function defaultReadFile(filePath) {
3059
3059
  }
3060
3060
  function buildResolveContext(input, opts) {
3061
3061
  const tokens = tokenize(input);
3062
- assertNonEmptyDocument(tokens, "input");
3063
3062
  const ast = parseTokens(tokens);
3063
+ if (ast.kind === "array") {
3064
+ const origin = opts.originDescription ?? "input";
3065
+ throw new ConfigError(
3066
+ `${origin}: ${ast.pos.line}:${ast.pos.col}: document has type array rather than object at file root (HOCON.md L989-991); the Config API requires an object root`,
3067
+ ""
3068
+ );
3069
+ }
3064
3070
  const resolveOpts = {
3065
3071
  env: getEnv(opts),
3066
3072
  baseDir: opts.baseDir,
package/dist/index.js CHANGED
@@ -80,7 +80,7 @@ function parseDuration(value, outputUnit = "ms") {
80
80
  if (i === 0) return NaN;
81
81
  const num = Number(trimmed.slice(0, i));
82
82
  if (Number.isNaN(num)) return NaN;
83
- const unit = trimHoconWs(trimmed.slice(i)).toLowerCase();
83
+ const unit = trimHoconWs(trimmed.slice(i));
84
84
  const divisor = OUTPUT_DURATION_UNITS[outputUnit];
85
85
  if (divisor === void 0) return NaN;
86
86
  if (unit === "") {
@@ -640,20 +640,6 @@ function isFileNotFoundError(e) {
640
640
  import { createRequire } from "module";
641
641
  import * as nodePath from "path";
642
642
 
643
- // src/internal/parser/empty-check.ts
644
- function hasContentTokens(tokens) {
645
- return tokens.some((t) => t.kind !== "eof" && t.kind !== "newline");
646
- }
647
- function assertNonEmptyDocument(tokens, sourceDescriptor) {
648
- if (!hasContentTokens(tokens)) {
649
- throw new ParseError(
650
- `empty file is not a valid HOCON document (HOCON.md L130): ${sourceDescriptor}`,
651
- 1,
652
- 1
653
- );
654
- }
655
- }
656
-
657
643
  // src/internal/lexer/lexer.ts
658
644
  function isHoconWhitespace(ch) {
659
645
  const cp = ch.codePointAt(0) ?? -1;
@@ -1099,6 +1085,16 @@ var Parser = class {
1099
1085
  }
1100
1086
  return { kind: "object", fields: allFields, pos: first.pos };
1101
1087
  }
1088
+ if (t.kind === "lbracket") {
1089
+ this.advance();
1090
+ const arr = this.parseArray();
1091
+ this.skip("newline");
1092
+ const remaining = this.peek();
1093
+ if (remaining.kind !== "eof") {
1094
+ throw new ParseError(`Unexpected token '${remaining.value}' after root array`, remaining.line, remaining.col);
1095
+ }
1096
+ return { ...arr, pos: { line: t.line, col: t.col } };
1097
+ }
1102
1098
  return this.parseObject(false);
1103
1099
  }
1104
1100
  peek(offset = 0) {
@@ -1537,6 +1533,16 @@ var _require = createRequire(import.meta.url);
1537
1533
  function isPnpPath(p) {
1538
1534
  return p.includes(".yarn/cache") || p.includes(".pnp.cjs") || p.includes(".pnp.loader");
1539
1535
  }
1536
+ function assertObjectRootedInclude(ast, sourcePath) {
1537
+ if (ast.kind === "array") {
1538
+ throw new ResolveError(
1539
+ `included file has array at file root \u2014 an included file must contain an object, not an array (HOCON.md L993-994): ${sourcePath}`,
1540
+ sourcePath,
1541
+ ast.pos.line,
1542
+ ast.pos.col
1543
+ );
1544
+ }
1545
+ }
1540
1546
  function validatePackageFile(file, identifier) {
1541
1547
  if (file.length === 0) {
1542
1548
  throw new ParseError(`include package("${identifier}", ...): file argument must be non-empty`, 0, 0);
@@ -1716,10 +1722,9 @@ var IncludeLoader = class {
1716
1722
  const resolver = packageResolver ?? ((id, f, includingFile, bDir) => defaultPackageResolver(id, f, includingFile, bDir, resolveFrom));
1717
1723
  const resolvedPath = resolver(identifier, file, void 0, baseDir);
1718
1724
  const content = readFileSync2(resolvedPath);
1719
- if (content.length === 0) return makeResObj();
1720
1725
  const tokens = tokenize(content);
1721
- assertNonEmptyDocument(tokens, resolvedPath);
1722
1726
  const ast = parseTokens(tokens);
1727
+ assertObjectRootedInclude(ast, resolvedPath);
1723
1728
  return this.onBuildResObj(ast, {
1724
1729
  ...this.opts,
1725
1730
  baseDir: nodePath.dirname(resolvedPath),
@@ -1749,10 +1754,9 @@ var IncludeLoader = class {
1749
1754
  const resolvedPath = resolver(identifier, file, void 0, baseDir);
1750
1755
  const read = readFile ? async (p) => readFile(p) : async (p) => readFileSync2(p);
1751
1756
  const content = await read(resolvedPath);
1752
- if (content.length === 0) return makeResObj();
1753
1757
  const tokens = tokenize(content);
1754
- assertNonEmptyDocument(tokens, resolvedPath);
1755
1758
  const ast = parseTokens(tokens);
1759
+ assertObjectRootedInclude(ast, resolvedPath);
1756
1760
  return this.onBuildResObjAsync(ast, {
1757
1761
  ...this.opts,
1758
1762
  baseDir: nodePath.dirname(resolvedPath),
@@ -1775,10 +1779,8 @@ var IncludeLoader = class {
1775
1779
  return hoconValueToResObj(propertiesToHoconValue(content));
1776
1780
  }
1777
1781
  const tokens = tokenize(content);
1778
- if (!hasContentTokens(tokens)) {
1779
- return makeResObj();
1780
- }
1781
1782
  const ast = parseTokens(tokens);
1783
+ assertObjectRootedInclude(ast, candidate);
1782
1784
  return this.onBuildResObj(ast, {
1783
1785
  ...this.opts,
1784
1786
  baseDir: nodePath.dirname(candidate),
@@ -1802,10 +1804,8 @@ var IncludeLoader = class {
1802
1804
  return hoconValueToResObj(propertiesToHoconValue(content));
1803
1805
  }
1804
1806
  const tokens = tokenize(content);
1805
- if (!hasContentTokens(tokens)) {
1806
- return makeResObj();
1807
- }
1808
1807
  const ast = parseTokens(tokens);
1808
+ assertObjectRootedInclude(ast, candidate);
1809
1809
  return this.onBuildResObjAsync(ast, {
1810
1810
  ...this.opts,
1811
1811
  baseDir: nodePath.dirname(candidate),
@@ -2997,8 +2997,14 @@ async function defaultReadFile(filePath) {
2997
2997
  }
2998
2998
  function buildResolveContext(input, opts) {
2999
2999
  const tokens = tokenize(input);
3000
- assertNonEmptyDocument(tokens, "input");
3001
3000
  const ast = parseTokens(tokens);
3001
+ if (ast.kind === "array") {
3002
+ const origin = opts.originDescription ?? "input";
3003
+ throw new ConfigError(
3004
+ `${origin}: ${ast.pos.line}:${ast.pos.col}: document has type array rather than object at file root (HOCON.md L989-991); the Config API requires an object root`,
3005
+ ""
3006
+ );
3007
+ }
3002
3008
  const resolveOpts = {
3003
3009
  env: getEnv(opts),
3004
3010
  baseDir: opts.baseDir,
package/dist/zod.cjs CHANGED
@@ -119,7 +119,7 @@ function parseDuration(value, outputUnit = "ms") {
119
119
  if (i === 0) return NaN;
120
120
  const num = Number(trimmed.slice(0, i));
121
121
  if (Number.isNaN(num)) return NaN;
122
- const unit = trimHoconWs(trimmed.slice(i)).toLowerCase();
122
+ const unit = trimHoconWs(trimmed.slice(i));
123
123
  const divisor = OUTPUT_DURATION_UNITS[outputUnit];
124
124
  if (divisor === void 0) return NaN;
125
125
  if (unit === "") {
@@ -683,20 +683,6 @@ function isFileNotFoundError(e) {
683
683
  var import_node_module = require("module");
684
684
  var nodePath = __toESM(require("path"), 1);
685
685
 
686
- // src/internal/parser/empty-check.ts
687
- function hasContentTokens(tokens) {
688
- return tokens.some((t) => t.kind !== "eof" && t.kind !== "newline");
689
- }
690
- function assertNonEmptyDocument(tokens, sourceDescriptor) {
691
- if (!hasContentTokens(tokens)) {
692
- throw new ParseError(
693
- `empty file is not a valid HOCON document (HOCON.md L130): ${sourceDescriptor}`,
694
- 1,
695
- 1
696
- );
697
- }
698
- }
699
-
700
686
  // src/internal/lexer/lexer.ts
701
687
  function isHoconWhitespace(ch) {
702
688
  const cp = ch.codePointAt(0) ?? -1;
@@ -1142,6 +1128,16 @@ var Parser = class {
1142
1128
  }
1143
1129
  return { kind: "object", fields: allFields, pos: first.pos };
1144
1130
  }
1131
+ if (t.kind === "lbracket") {
1132
+ this.advance();
1133
+ const arr = this.parseArray();
1134
+ this.skip("newline");
1135
+ const remaining = this.peek();
1136
+ if (remaining.kind !== "eof") {
1137
+ throw new ParseError(`Unexpected token '${remaining.value}' after root array`, remaining.line, remaining.col);
1138
+ }
1139
+ return { ...arr, pos: { line: t.line, col: t.col } };
1140
+ }
1145
1141
  return this.parseObject(false);
1146
1142
  }
1147
1143
  peek(offset = 0) {
@@ -1581,6 +1577,16 @@ var _require = (0, import_node_module.createRequire)(import_meta.url);
1581
1577
  function isPnpPath(p) {
1582
1578
  return p.includes(".yarn/cache") || p.includes(".pnp.cjs") || p.includes(".pnp.loader");
1583
1579
  }
1580
+ function assertObjectRootedInclude(ast, sourcePath) {
1581
+ if (ast.kind === "array") {
1582
+ throw new ResolveError(
1583
+ `included file has array at file root \u2014 an included file must contain an object, not an array (HOCON.md L993-994): ${sourcePath}`,
1584
+ sourcePath,
1585
+ ast.pos.line,
1586
+ ast.pos.col
1587
+ );
1588
+ }
1589
+ }
1584
1590
  function validatePackageFile(file, identifier) {
1585
1591
  if (file.length === 0) {
1586
1592
  throw new ParseError(`include package("${identifier}", ...): file argument must be non-empty`, 0, 0);
@@ -1760,10 +1766,9 @@ var IncludeLoader = class {
1760
1766
  const resolver = packageResolver ?? ((id, f, includingFile, bDir) => defaultPackageResolver(id, f, includingFile, bDir, resolveFrom));
1761
1767
  const resolvedPath = resolver(identifier, file, void 0, baseDir);
1762
1768
  const content = readFileSync2(resolvedPath);
1763
- if (content.length === 0) return makeResObj();
1764
1769
  const tokens = tokenize(content);
1765
- assertNonEmptyDocument(tokens, resolvedPath);
1766
1770
  const ast = parseTokens(tokens);
1771
+ assertObjectRootedInclude(ast, resolvedPath);
1767
1772
  return this.onBuildResObj(ast, {
1768
1773
  ...this.opts,
1769
1774
  baseDir: nodePath.dirname(resolvedPath),
@@ -1793,10 +1798,9 @@ var IncludeLoader = class {
1793
1798
  const resolvedPath = resolver(identifier, file, void 0, baseDir);
1794
1799
  const read = readFile ? async (p) => readFile(p) : async (p) => readFileSync2(p);
1795
1800
  const content = await read(resolvedPath);
1796
- if (content.length === 0) return makeResObj();
1797
1801
  const tokens = tokenize(content);
1798
- assertNonEmptyDocument(tokens, resolvedPath);
1799
1802
  const ast = parseTokens(tokens);
1803
+ assertObjectRootedInclude(ast, resolvedPath);
1800
1804
  return this.onBuildResObjAsync(ast, {
1801
1805
  ...this.opts,
1802
1806
  baseDir: nodePath.dirname(resolvedPath),
@@ -1819,10 +1823,8 @@ var IncludeLoader = class {
1819
1823
  return hoconValueToResObj(propertiesToHoconValue(content));
1820
1824
  }
1821
1825
  const tokens = tokenize(content);
1822
- if (!hasContentTokens(tokens)) {
1823
- return makeResObj();
1824
- }
1825
1826
  const ast = parseTokens(tokens);
1827
+ assertObjectRootedInclude(ast, candidate);
1826
1828
  return this.onBuildResObj(ast, {
1827
1829
  ...this.opts,
1828
1830
  baseDir: nodePath.dirname(candidate),
@@ -1846,10 +1848,8 @@ var IncludeLoader = class {
1846
1848
  return hoconValueToResObj(propertiesToHoconValue(content));
1847
1849
  }
1848
1850
  const tokens = tokenize(content);
1849
- if (!hasContentTokens(tokens)) {
1850
- return makeResObj();
1851
- }
1852
1851
  const ast = parseTokens(tokens);
1852
+ assertObjectRootedInclude(ast, candidate);
1853
1853
  return this.onBuildResObjAsync(ast, {
1854
1854
  ...this.opts,
1855
1855
  baseDir: nodePath.dirname(candidate),
@@ -3026,8 +3026,14 @@ function defaultReadFileSync(filePath) {
3026
3026
  }
3027
3027
  function buildResolveContext(input, opts) {
3028
3028
  const tokens = tokenize(input);
3029
- assertNonEmptyDocument(tokens, "input");
3030
3029
  const ast = parseTokens(tokens);
3030
+ if (ast.kind === "array") {
3031
+ const origin = opts.originDescription ?? "input";
3032
+ throw new ConfigError(
3033
+ `${origin}: ${ast.pos.line}:${ast.pos.col}: document has type array rather than object at file root (HOCON.md L989-991); the Config API requires an object root`,
3034
+ ""
3035
+ );
3036
+ }
3031
3037
  const resolveOpts = {
3032
3038
  env: getEnv(opts),
3033
3039
  baseDir: opts.baseDir,
package/dist/zod.js CHANGED
@@ -80,7 +80,7 @@ function parseDuration(value, outputUnit = "ms") {
80
80
  if (i === 0) return NaN;
81
81
  const num = Number(trimmed.slice(0, i));
82
82
  if (Number.isNaN(num)) return NaN;
83
- const unit = trimHoconWs(trimmed.slice(i)).toLowerCase();
83
+ const unit = trimHoconWs(trimmed.slice(i));
84
84
  const divisor = OUTPUT_DURATION_UNITS[outputUnit];
85
85
  if (divisor === void 0) return NaN;
86
86
  if (unit === "") {
@@ -644,20 +644,6 @@ function isFileNotFoundError(e) {
644
644
  import { createRequire } from "module";
645
645
  import * as nodePath from "path";
646
646
 
647
- // src/internal/parser/empty-check.ts
648
- function hasContentTokens(tokens) {
649
- return tokens.some((t) => t.kind !== "eof" && t.kind !== "newline");
650
- }
651
- function assertNonEmptyDocument(tokens, sourceDescriptor) {
652
- if (!hasContentTokens(tokens)) {
653
- throw new ParseError(
654
- `empty file is not a valid HOCON document (HOCON.md L130): ${sourceDescriptor}`,
655
- 1,
656
- 1
657
- );
658
- }
659
- }
660
-
661
647
  // src/internal/lexer/lexer.ts
662
648
  function isHoconWhitespace(ch) {
663
649
  const cp = ch.codePointAt(0) ?? -1;
@@ -1103,6 +1089,16 @@ var Parser = class {
1103
1089
  }
1104
1090
  return { kind: "object", fields: allFields, pos: first.pos };
1105
1091
  }
1092
+ if (t.kind === "lbracket") {
1093
+ this.advance();
1094
+ const arr = this.parseArray();
1095
+ this.skip("newline");
1096
+ const remaining = this.peek();
1097
+ if (remaining.kind !== "eof") {
1098
+ throw new ParseError(`Unexpected token '${remaining.value}' after root array`, remaining.line, remaining.col);
1099
+ }
1100
+ return { ...arr, pos: { line: t.line, col: t.col } };
1101
+ }
1106
1102
  return this.parseObject(false);
1107
1103
  }
1108
1104
  peek(offset = 0) {
@@ -1541,6 +1537,16 @@ var _require = createRequire(import.meta.url);
1541
1537
  function isPnpPath(p) {
1542
1538
  return p.includes(".yarn/cache") || p.includes(".pnp.cjs") || p.includes(".pnp.loader");
1543
1539
  }
1540
+ function assertObjectRootedInclude(ast, sourcePath) {
1541
+ if (ast.kind === "array") {
1542
+ throw new ResolveError(
1543
+ `included file has array at file root \u2014 an included file must contain an object, not an array (HOCON.md L993-994): ${sourcePath}`,
1544
+ sourcePath,
1545
+ ast.pos.line,
1546
+ ast.pos.col
1547
+ );
1548
+ }
1549
+ }
1544
1550
  function validatePackageFile(file, identifier) {
1545
1551
  if (file.length === 0) {
1546
1552
  throw new ParseError(`include package("${identifier}", ...): file argument must be non-empty`, 0, 0);
@@ -1720,10 +1726,9 @@ var IncludeLoader = class {
1720
1726
  const resolver = packageResolver ?? ((id, f, includingFile, bDir) => defaultPackageResolver(id, f, includingFile, bDir, resolveFrom));
1721
1727
  const resolvedPath = resolver(identifier, file, void 0, baseDir);
1722
1728
  const content = readFileSync2(resolvedPath);
1723
- if (content.length === 0) return makeResObj();
1724
1729
  const tokens = tokenize(content);
1725
- assertNonEmptyDocument(tokens, resolvedPath);
1726
1730
  const ast = parseTokens(tokens);
1731
+ assertObjectRootedInclude(ast, resolvedPath);
1727
1732
  return this.onBuildResObj(ast, {
1728
1733
  ...this.opts,
1729
1734
  baseDir: nodePath.dirname(resolvedPath),
@@ -1753,10 +1758,9 @@ var IncludeLoader = class {
1753
1758
  const resolvedPath = resolver(identifier, file, void 0, baseDir);
1754
1759
  const read = readFile ? async (p) => readFile(p) : async (p) => readFileSync2(p);
1755
1760
  const content = await read(resolvedPath);
1756
- if (content.length === 0) return makeResObj();
1757
1761
  const tokens = tokenize(content);
1758
- assertNonEmptyDocument(tokens, resolvedPath);
1759
1762
  const ast = parseTokens(tokens);
1763
+ assertObjectRootedInclude(ast, resolvedPath);
1760
1764
  return this.onBuildResObjAsync(ast, {
1761
1765
  ...this.opts,
1762
1766
  baseDir: nodePath.dirname(resolvedPath),
@@ -1779,10 +1783,8 @@ var IncludeLoader = class {
1779
1783
  return hoconValueToResObj(propertiesToHoconValue(content));
1780
1784
  }
1781
1785
  const tokens = tokenize(content);
1782
- if (!hasContentTokens(tokens)) {
1783
- return makeResObj();
1784
- }
1785
1786
  const ast = parseTokens(tokens);
1787
+ assertObjectRootedInclude(ast, candidate);
1786
1788
  return this.onBuildResObj(ast, {
1787
1789
  ...this.opts,
1788
1790
  baseDir: nodePath.dirname(candidate),
@@ -1806,10 +1808,8 @@ var IncludeLoader = class {
1806
1808
  return hoconValueToResObj(propertiesToHoconValue(content));
1807
1809
  }
1808
1810
  const tokens = tokenize(content);
1809
- if (!hasContentTokens(tokens)) {
1810
- return makeResObj();
1811
- }
1812
1811
  const ast = parseTokens(tokens);
1812
+ assertObjectRootedInclude(ast, candidate);
1813
1813
  return this.onBuildResObjAsync(ast, {
1814
1814
  ...this.opts,
1815
1815
  baseDir: nodePath.dirname(candidate),
@@ -2986,8 +2986,14 @@ function defaultReadFileSync(filePath) {
2986
2986
  }
2987
2987
  function buildResolveContext(input, opts) {
2988
2988
  const tokens = tokenize(input);
2989
- assertNonEmptyDocument(tokens, "input");
2990
2989
  const ast = parseTokens(tokens);
2990
+ if (ast.kind === "array") {
2991
+ const origin = opts.originDescription ?? "input";
2992
+ throw new ConfigError(
2993
+ `${origin}: ${ast.pos.line}:${ast.pos.col}: document has type array rather than object at file root (HOCON.md L989-991); the Config API requires an object root`,
2994
+ ""
2995
+ );
2996
+ }
2991
2997
  const resolveOpts = {
2992
2998
  env: getEnv(opts),
2993
2999
  baseDir: opts.baseDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/ts.hocon",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Full Lightbend HOCON spec-compliant parser and config library for TypeScript",
5
5
  "keywords": [
6
6
  "hocon",
@@ -29,6 +29,15 @@
29
29
  "files": [
30
30
  "dist"
31
31
  ],
32
+ "scripts": {
33
+ "build": "tsup",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest",
36
+ "typecheck": "tsc --noEmit",
37
+ "coverage": "vitest run --coverage",
38
+ "bench": "vitest bench",
39
+ "docs": "typedoc"
40
+ },
32
41
  "peerDependencies": {
33
42
  "zod": ">=4.0.0"
34
43
  },
@@ -39,18 +48,18 @@
39
48
  },
40
49
  "devDependencies": {
41
50
  "@types/js-yaml": "^4.0.9",
42
- "@types/node": "^25.5.0",
43
- "@vitest/coverage-v8": "4.1.2",
44
- "js-yaml": "^4.1.1",
51
+ "@types/node": "^25.9.5",
52
+ "@vitest/coverage-v8": "4.1.10",
53
+ "js-yaml": "^4.3.0",
45
54
  "tsup": "^8.5.1",
46
- "typedoc": "^0.28.18",
55
+ "typedoc": "^0.28.20",
47
56
  "typescript": "^5.9.3",
48
- "vitest": "4.1.2",
49
- "zod": "^4.3.6"
57
+ "vitest": "4.1.10",
58
+ "zod": "^4.4.3"
50
59
  },
51
60
  "repository": {
52
61
  "type": "git",
53
- "url": "https://github.com/o3co/ts.hocon"
62
+ "url": "git+https://github.com/o3co/ts.hocon.git"
54
63
  },
55
64
  "license": "Apache-2.0",
56
65
  "engines": {
@@ -58,13 +67,5 @@
58
67
  },
59
68
  "homepage": "https://o3co.github.io/ts.hocon/",
60
69
  "bugs": "https://github.com/o3co/ts.hocon/issues",
61
- "scripts": {
62
- "build": "tsup",
63
- "test": "vitest run",
64
- "test:watch": "vitest",
65
- "typecheck": "tsc --noEmit",
66
- "coverage": "vitest run --coverage",
67
- "bench": "vitest bench",
68
- "docs": "typedoc"
69
- }
70
- }
70
+ "packageManager": "pnpm@10.30.2"
71
+ }