@o3co/ts.hocon 0.1.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.
Files changed (97) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  3. package/.github/pull_request_template.md +17 -0
  4. package/.github/workflows/lint.yml +33 -0
  5. package/.github/workflows/release.yml +41 -0
  6. package/.github/workflows/test.yml +53 -0
  7. package/CHANGELOG.md +22 -0
  8. package/CONTRIBUTING.md +62 -0
  9. package/LICENSE +202 -0
  10. package/README.ja.md +195 -0
  11. package/README.md +195 -0
  12. package/SECURITY.md +20 -0
  13. package/docs/superpowers/plans/2026-03-20-ts-hocon.md +2412 -0
  14. package/docs/superpowers/specs/2026-03-20-ts-hocon-design.md +368 -0
  15. package/package.json +44 -0
  16. package/src/config.ts +108 -0
  17. package/src/errors.ts +34 -0
  18. package/src/index.ts +5 -0
  19. package/src/internal/lexer/lexer.ts +168 -0
  20. package/src/internal/lexer/token.ts +20 -0
  21. package/src/internal/parser/ast.ts +17 -0
  22. package/src/internal/parser/parser.ts +229 -0
  23. package/src/internal/resolver/resolver.ts +428 -0
  24. package/src/parse.ts +79 -0
  25. package/src/value.ts +5 -0
  26. package/src/zod.ts +12 -0
  27. package/tests/config.test.ts +100 -0
  28. package/tests/errors.test.ts +43 -0
  29. package/tests/lexer.test.ts +141 -0
  30. package/tests/lightbend/lightbend.test.ts +39 -0
  31. package/tests/lightbend/testdata/a_1.conf +1 -0
  32. package/tests/lightbend/testdata/b_2.conf +1 -0
  33. package/tests/lightbend/testdata/beanconfig/beanconfig01.conf +131 -0
  34. package/tests/lightbend/testdata/bom.conf +2 -0
  35. package/tests/lightbend/testdata/cycle.conf +1 -0
  36. package/tests/lightbend/testdata/env-variables.conf +16 -0
  37. package/tests/lightbend/testdata/equiv01/comments.conf +45 -0
  38. package/tests/lightbend/testdata/equiv01/equals.conf +41 -0
  39. package/tests/lightbend/testdata/equiv01/no-commas.conf +55 -0
  40. package/tests/lightbend/testdata/equiv01/no-root-braces.conf +40 -0
  41. package/tests/lightbend/testdata/equiv01/no-whitespace.json +1 -0
  42. package/tests/lightbend/testdata/equiv01/omit-colons.conf +44 -0
  43. package/tests/lightbend/testdata/equiv01/original.json +41 -0
  44. package/tests/lightbend/testdata/equiv01/path-keys.conf +29 -0
  45. package/tests/lightbend/testdata/equiv01/properties-style.conf +37 -0
  46. package/tests/lightbend/testdata/equiv01/substitutions.conf +41 -0
  47. package/tests/lightbend/testdata/equiv01/unquoted.conf +41 -0
  48. package/tests/lightbend/testdata/equiv02/original.json +17 -0
  49. package/tests/lightbend/testdata/equiv02/path-keys-weird-whitespace.conf +32 -0
  50. package/tests/lightbend/testdata/equiv02/path-keys.conf +9 -0
  51. package/tests/lightbend/testdata/equiv03/includes.conf +7 -0
  52. package/tests/lightbend/testdata/equiv03/letters/a.conf +6 -0
  53. package/tests/lightbend/testdata/equiv03/letters/b.json +3 -0
  54. package/tests/lightbend/testdata/equiv03/letters/c.conf +2 -0
  55. package/tests/lightbend/testdata/equiv03/letters/c.properties +1 -0
  56. package/tests/lightbend/testdata/equiv03/letters/numbers/1.conf +1 -0
  57. package/tests/lightbend/testdata/equiv03/letters/numbers/2.properties +1 -0
  58. package/tests/lightbend/testdata/equiv03/original.json +15 -0
  59. package/tests/lightbend/testdata/equiv03/root/foo.conf +1 -0
  60. package/tests/lightbend/testdata/equiv04/missing-substitutions.conf +4 -0
  61. package/tests/lightbend/testdata/equiv04/original.json +3 -0
  62. package/tests/lightbend/testdata/equiv05/original.json +16 -0
  63. package/tests/lightbend/testdata/equiv05/triple-quotes.conf +20 -0
  64. package/tests/lightbend/testdata/file-include.conf +5 -0
  65. package/tests/lightbend/testdata/include-from-list.conf +5 -0
  66. package/tests/lightbend/testdata/onclasspath.conf +2 -0
  67. package/tests/lightbend/testdata/subdir/bar-file.conf +1 -0
  68. package/tests/lightbend/testdata/subdir/bar.conf +1 -0
  69. package/tests/lightbend/testdata/subdir/baz.conf +1 -0
  70. package/tests/lightbend/testdata/subdir/foo.conf +5 -0
  71. package/tests/lightbend/testdata/test01.conf +96 -0
  72. package/tests/lightbend/testdata/test01.json +4 -0
  73. package/tests/lightbend/testdata/test01.properties +5 -0
  74. package/tests/lightbend/testdata/test02.conf +13 -0
  75. package/tests/lightbend/testdata/test03-included.conf +9 -0
  76. package/tests/lightbend/testdata/test03.conf +36 -0
  77. package/tests/lightbend/testdata/test04.conf +305 -0
  78. package/tests/lightbend/testdata/test05.conf +154 -0
  79. package/tests/lightbend/testdata/test06.conf +14 -0
  80. package/tests/lightbend/testdata/test07.conf +4 -0
  81. package/tests/lightbend/testdata/test08.conf +6 -0
  82. package/tests/lightbend/testdata/test09.conf +17 -0
  83. package/tests/lightbend/testdata/test10.conf +11 -0
  84. package/tests/lightbend/testdata/test11.conf +7 -0
  85. package/tests/lightbend/testdata/test12.conf +43 -0
  86. package/tests/lightbend/testdata/test13-application-override-substitutions.conf +1 -0
  87. package/tests/lightbend/testdata/test13-reference-bad-substitutions.conf +1 -0
  88. package/tests/lightbend/testdata/test13-reference-with-substitutions.conf +2 -0
  89. package/tests/lightbend/testdata/validate-invalid.conf +30 -0
  90. package/tests/lightbend/testdata/validate-reference.conf +32 -0
  91. package/tests/parse.test.ts +44 -0
  92. package/tests/parser.test.ts +154 -0
  93. package/tests/resolver.test.ts +159 -0
  94. package/tests/zod.test.ts +57 -0
  95. package/tsconfig.json +16 -0
  96. package/tsup.config.ts +12 -0
  97. package/vitest.config.ts +7 -0
@@ -0,0 +1,368 @@
1
+ # @o3co/ts.hocon — Design Spec
2
+
3
+ **Date:** 2026-03-20
4
+ **Status:** Approved
5
+
6
+ ---
7
+
8
+ ## Overview
9
+
10
+ `@o3co/ts.hocon` は TypeScript 製の HOCON パーサーライブラリ。
11
+ Go 実装 (`go.hocon`) の設計を踏襲しつつ、TypeScript イディオムに沿った公開 API を提供する。
12
+ Node.js をプライマリターゲットとし、ブラウザ互換も目指す。
13
+
14
+ ---
15
+
16
+ ## Goals
17
+
18
+ - HOCON 仕様への完全準拠(Lightbend 公式テストスイート通過)
19
+ - 同期 / 非同期 API の両提供
20
+ - ESM + CJS デュアルパッケージ
21
+ - Zod インテグレーション(オプション peer dependency)
22
+ - TypeScript strict モードで型安全に使えること
23
+
24
+ ---
25
+
26
+ ## Project Structure
27
+
28
+ ```text
29
+ @o3co/ts.hocon/
30
+ ├── src/
31
+ │ ├── index.ts # public exports
32
+ │ ├── parse.ts # parse / parseAsync / parseFile / parseFileAsync
33
+ │ ├── config.ts # Config クラス
34
+ │ ├── value.ts # HoconValue 判別共用体
35
+ │ ├── errors.ts # ParseError / ResolveError / ConfigError
36
+ │ ├── zod.ts # Zod インテグレーション(peer dep)
37
+ │ └── internal/
38
+ │ ├── lexer/
39
+ │ │ ├── lexer.ts
40
+ │ │ └── token.ts
41
+ │ ├── parser/
42
+ │ │ ├── parser.ts
43
+ │ │ └── ast.ts
44
+ │ └── resolver/
45
+ │ └── resolver.ts
46
+ ├── tests/
47
+ │ ├── lightbend/ # Lightbend 公式テストスイート
48
+ │ ├── lexer.test.ts
49
+ │ ├── parser.test.ts
50
+ │ ├── resolver.test.ts
51
+ │ └── config.test.ts
52
+ ├── package.json
53
+ ├── tsconfig.json
54
+ └── vitest.config.ts
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Tooling
60
+
61
+ | 役割 | ツール |
62
+ | ---------- | ------------------------------------ |
63
+ | ビルド | tsup(ESM + CJS デュアルパッケージ) |
64
+ | テスト | Vitest |
65
+ | 型チェック | TypeScript strict |
66
+ | Zod | peer dependency(オプション) |
67
+
68
+ ---
69
+
70
+ ## Internal Architecture
71
+
72
+ ### Pipeline
73
+
74
+ ```text
75
+ 文字列 / ファイル
76
+ ↓ Lexer → Token[]
77
+ ↓ Parser → AstNode(ObjectNode がルート)
78
+ ↓ Resolver → HoconValue(2パス)
79
+ ↓ Config → 公開 API
80
+ ```
81
+
82
+ ### AST 型(Parser 出力)
83
+
84
+ ```ts
85
+ // src/internal/parser/ast.ts
86
+
87
+ type Pos = { line: number; col: number; file?: string }
88
+
89
+ type AstNode =
90
+ | { kind: "object"; fields: AstField[]; pos: Pos }
91
+ | { kind: "array"; items: AstNode[]; pos: Pos }
92
+ | { kind: "scalar"; value: string | number | boolean | null; pos: Pos }
93
+ | { kind: "concat"; nodes: AstNode[]; pos: Pos }
94
+ | { kind: "subst"; path: string; optional: boolean; pos: Pos }
95
+ | { kind: "include"; path: string; pos: Pos }
96
+
97
+ // key が空配列の場合は include ディレクティブを表す(Go の synthetic field と同じ意味)
98
+ type AstField = {
99
+ key: string[] // ドット記法を分割済み。quoted キーはドット分割しない
100
+ value: AstNode
101
+ append: boolean // true なら += 演算子
102
+ pos: Pos
103
+ }
104
+ ```
105
+
106
+ **注意:quoted キーのドット分割**
107
+ `"a.b" = 1` のようにクォートされたキーはドット分割しない。
108
+ `key` 配列の各要素は、lexer が `isQuoted` フラグを付けた場合はそのままリテラルとして扱う。
109
+
110
+ ### 解決済み値型(Resolver 出力)
111
+
112
+ ```ts
113
+ // src/value.ts
114
+
115
+ // JavaScript の Map は挿入順を保証するため、keys[] フィールドは不要
116
+ type HoconValue =
117
+ | { kind: "object"; fields: Map<string, HoconValue> } // Map のイテレーション順 = 宣言順
118
+ | { kind: "array"; items: HoconValue[] }
119
+ | { kind: "scalar"; value: string | number | boolean | null }
120
+ ```
121
+
122
+ **数値型の扱い:**
123
+ Go の `int64` / `float64` の区別は TypeScript では不要。
124
+ Lexer は整数リテラル → `number`、浮動小数点リテラル → `number` として扱う(両方 JS `number`)。
125
+ `1` と `1.0` は内部的に同じ値になる。`z.number().int()` は `Number.isInteger()` で判定される。
126
+
127
+ ### Resolver:2パス解決
128
+
129
+ Go 版と同じアルゴリズム。
130
+
131
+ #### Pass 1:構造構築とマージ
132
+
133
+ ```ts
134
+ // 内部センチネル型(src/internal/resolver/resolver.ts 内部のみ)
135
+ type SubstPlaceholder = {
136
+ _kind: "subst-placeholder"
137
+ path: string
138
+ optional: boolean
139
+ pos: Pos
140
+ }
141
+ type ConcatPlaceholder = {
142
+ _kind: "concat-placeholder"
143
+ nodes: (HoconValue | SubstPlaceholder | ConcatPlaceholder)[]
144
+ pos: Pos
145
+ }
146
+ type ResolverValue = HoconValue | SubstPlaceholder | ConcatPlaceholder
147
+ ```
148
+
149
+ - オブジェクト構造の構築・深マージ
150
+ - substitution ノード → `SubstPlaceholder`、concat ノード → `ConcatPlaceholder` として保持
151
+ - キーが上書きされるとき、`priorValues: Map<string, ResolverValue>` に上書き前の値を保存(自己参照 substitution 用)
152
+ - `priorValues` はオブジェクトスコープごとに管理する(Go 版と同じ)
153
+
154
+ #### Pass 2:代替解決
155
+
156
+ - `resolving: Set<string>` でサイクルを検出
157
+ - `${path}` → パスが見つからなければ `env[path]` にフォールバック(`process.env` または `ParseOptions.env`)
158
+ - `${?path}` → パスが見つからなければフィールドごと削除(エラーなし)
159
+ - 自己参照は `priorValues` を参照して解決
160
+ - `include` ディレクティブも pass 2 で解決し、深マージする
161
+
162
+ **include サイクル検出:**
163
+ `includeStack: string[]` でインクルードチェーンを追跡し、同一パスが2回現れたら `ResolveError` をスローする。
164
+
165
+ **`+=` 演算子:**
166
+ 対象キーが存在しない場合は空配列 `[]` を初期値として扱う(Go 版と同じ)。
167
+
168
+ ---
169
+
170
+ ## Public API
171
+
172
+ ### パース関数
173
+
174
+ ```ts
175
+ // src/parse.ts
176
+
177
+ function parse(input: string, options?: ParseOptions): Config
178
+ function parseFile(path: string, options?: ParseOptions): Config
179
+ function parseAsync(input: string, options?: ParseOptions): Promise<Config>
180
+ function parseFileAsync(path: string, options?: ParseOptions): Promise<Config>
181
+
182
+ type ParseOptions = {
183
+ /**
184
+ * include ディレクティブの起点ディレクトリ。
185
+ * parseFile / parseFileAsync でこのオプションを省略した場合、
186
+ * 対象ファイルのディレクトリを自動的に baseDir として使用する。
187
+ * parse / parseAsync で省略した場合は process.cwd()(Node.js)または undefined(ブラウザ)。
188
+ */
189
+ baseDir?: string
190
+
191
+ /**
192
+ * 環境変数テーブル。substitution 解決時の process.env フォールバックに使う。
193
+ * 省略した場合: Node.js では process.env を使用、ブラウザでは {} を使用。
194
+ * ブラウザで process が未定義でもクラッシュしない(typeof process でガード)。
195
+ */
196
+ env?: Record<string, string>
197
+
198
+ /**
199
+ * ファイル読み込み関数。デフォルトは Node.js の fs.readFileSync / fs.promises.readFile。
200
+ * ブラウザやカスタム環境では差し替えて使う。
201
+ * parseAsync / parseFileAsync は readFile(非同期版)を使う。
202
+ * parse / parseFile は readFileSync(同期版)を使う。
203
+ */
204
+ readFile?: (path: string) => Promise<string>
205
+ readFileSync?: (path: string) => string
206
+ }
207
+ ```
208
+
209
+ **`parseAsync` の位置づけ:**
210
+ `parseAsync` は原則として同期パイプラインを `Promise.resolve()` でラップした非同期ラッパー。
211
+ ただし `include` ディレクティブが存在する場合は `readFile`(非同期)を呼び出すため本物の非同期処理になる。
212
+ `parse`(同期)と `parseAsync`(非同期)は API 対称性のために両方提供する。
213
+
214
+ ### Config クラス
215
+
216
+ ```ts
217
+ // src/config.ts
218
+
219
+ class Config {
220
+ // プレーンアクセス(型アサーション、なければ ConfigError をスロー)
221
+ get(path: string): unknown
222
+ getString(path: string): string
223
+ getNumber(path: string): number // 文字列値は数値に強制変換しない。型不一致は ConfigError
224
+ getBoolean(path: string): boolean
225
+ getConfig(path: string): Config
226
+ getList(path: string): unknown[] // 要素は toObject() 変換済みのプレーン JS 値
227
+
228
+ // Zod 付きアクセス(型安全、後述)
229
+ // ※ ZodType を直接型シグネチャに含めると peer dep 未インストール時にコンパイルエラーになるため、
230
+ // Zod 付きアクセスは src/zod.ts のヘルパー関数として提供する(後述)
231
+
232
+ // ユーティリティ
233
+ /**
234
+ * パスが存在するか確認する。値が null でも true を返す(Go 版と同じ)。
235
+ */
236
+ has(path: string): boolean
237
+ keys(): string[]
238
+ /**
239
+ * レシーバーの値を優先し、fallback はレシーバーにないキーのデフォルト値を提供する。
240
+ * Go 版の WithFallback と同じセマンティクス(receiver wins)。
241
+ */
242
+ withFallback(fallback: Config): Config
243
+ /**
244
+ * HoconValue ツリーをプレーン JS オブジェクトに再帰的に変換する。
245
+ * Map は Record<string, unknown> に変換される(JSON.stringify 互換)。
246
+ * 数値はすべて JS number として返す。
247
+ */
248
+ toObject(): unknown
249
+ }
250
+ ```
251
+
252
+ **`getNumber` のコーション:**
253
+ スカラー値が `number` 型の場合のみ成功。`string` 型は ConfigError(文字列 `"42"` は数値に変換しない)。
254
+ 期間や容量の文字列(`"10s"`, `"1MB"`)は `getString` で取得して呼び出し側で変換するか、Zod スキーマで変換する。
255
+
256
+ ### Zod インテグレーション
257
+
258
+ Zod は optional peer dependency。`ZodType` を `Config` クラスに直接含めると peer dep 未インストール時に TypeScript がコンパイルエラーを出すため、**Zod 関連の API は `src/zod.ts` のヘルパー関数として提供する**。
259
+
260
+ ```ts
261
+ // src/zod.ts
262
+ import type { ZodType, infer as ZodInfer } from 'zod'
263
+
264
+ /**
265
+ * Config 全体を Zod スキーマで検証する。
266
+ * config.toObject() の結果を schema.parse() に渡す。
267
+ * 検証失敗は ZodError をスロー。
268
+ */
269
+ export function validate<T>(config: Config, schema: ZodType<T>): T
270
+
271
+ /**
272
+ * 特定パスの値を Zod スキーマで検証する。
273
+ */
274
+ export function getValidated<T>(config: Config, path: string, schema: ZodType<T>): T
275
+ ```
276
+
277
+ **使用例:**
278
+
279
+ ```ts
280
+ import { parse } from '@o3co/ts.hocon'
281
+ import { validate, getValidated } from '@o3co/ts.hocon/zod'
282
+ import { z } from 'zod'
283
+
284
+ const cfg = parse(`server { host = "localhost", port = 8080 }`)
285
+
286
+ // フル検証
287
+ const AppSchema = z.object({ server: z.object({ host: z.string(), port: z.number().int() }) })
288
+ const app = validate(cfg, AppSchema) // z.infer<typeof AppSchema>
289
+
290
+ // パス指定アクセス
291
+ const port = getValidated(cfg, "server.port", z.number().int()) // number
292
+ ```
293
+
294
+ **変換フロー:**
295
+
296
+ ```text
297
+ HoconValue → config.toObject() → プレーン JS オブジェクト → schema.parse(obj)
298
+ ```
299
+
300
+ ---
301
+
302
+ ## Error Handling
303
+
304
+ ```ts
305
+ // src/errors.ts
306
+
307
+ class ParseError extends Error {
308
+ line: number
309
+ col: number
310
+ file?: string // include 先のファイルパス(ルートファイルでは undefined)
311
+ }
312
+
313
+ class ResolveError extends Error {
314
+ path: string // HOCON パス(例: "server.host")
315
+ line: number // 0 の場合あり(include エラー等、位置情報なし)
316
+ col: number // 0 の場合あり
317
+ file?: string
318
+ }
319
+
320
+ class ConfigError extends Error {
321
+ path: string // アクセスしようとしたパス
322
+ }
323
+ ```
324
+
325
+ `ResolveError.line` / `col` はケースによっては 0 になる(`include` に関連するエラーなど)。
326
+ 呼び出し側は 0 を「位置情報なし」として扱う。
327
+
328
+ ---
329
+
330
+ ## Testing Strategy
331
+
332
+ - **ユニットテスト:** Lexer / Parser / Resolver それぞれ独立してテスト
333
+ - **統合テスト:** `parse()` → `Config` の end-to-end
334
+ - **仕様準拠テスト:** Lightbend 公式テストスイート(`tests/lightbend/`)を Go 版と同様に取り込む
335
+ - **Zod テスト:** スキーマ検証・型推論の正しさを確認
336
+
337
+ ---
338
+
339
+ ## HOCON Spec Compliance
340
+
341
+ Go 版に準じて以下をサポート:
342
+
343
+ - オブジェクト・配列・スカラー値
344
+ - コメント(`#`、`//`)
345
+ - 複数行文字列(`"""`)
346
+ - ドット記法キー(`a.b.c`)、クォートキーはドット分割しない
347
+ - オブジェクトのマージ・上書き
348
+ - `+=` 追記演算子(対象キー未存在時は空配列を初期値とする)
349
+ - substitution:必須 `${path}`、オプション `${?path}`
350
+ - 自己参照 substitution(`priorValues` による解決)
351
+ - `include "file"` / `include file("file")`
352
+ - 環境変数フォールバック
353
+ - サイクル検出(substitution・include 両方)
354
+
355
+ 非対応(初期バージョン):
356
+
357
+ - `include url(...)`
358
+ - `include classpath(...)`
359
+ - `.properties` ファイル
360
+
361
+ ---
362
+
363
+ ## Browser Compatibility
364
+
365
+ - `parseFile` / `parseFileAsync` は Node.js 専用。ブラウザで呼ぶと明示的なエラーをスロー(`"parseFile is not supported in browser environments"`)。
366
+ - `parse()` / `parseAsync()` はブラウザ対応。
367
+ - `process.env` は `typeof process !== 'undefined'` でガードし、ブラウザでは `{}` を使う。
368
+ - ブラウザで `include` を使う場合は `ParseOptions.readFile` を指定する(HTTP fetch 等で実装可能)。
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@o3co/ts.hocon",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js",
9
+ "require": "./dist/index.cjs"
10
+ },
11
+ "./zod": {
12
+ "types": "./dist/zod.d.ts",
13
+ "import": "./dist/zod.js",
14
+ "require": "./dist/zod.cjs"
15
+ }
16
+ },
17
+ "main": "./dist/index.cjs",
18
+ "module": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "scripts": {
21
+ "build": "tsup",
22
+ "test": "vitest run",
23
+ "test:watch": "vitest",
24
+ "typecheck": "tsc --noEmit",
25
+ "coverage": "vitest run --coverage"
26
+ },
27
+ "peerDependencies": {
28
+ "zod": ">=3.0.0"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "zod": {
32
+ "optional": true
33
+ }
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^25.5.0",
37
+ "@vitest/coverage-v8": "^3.2.4",
38
+ "tsup": "^8.5.1",
39
+ "typescript": "^5.9.3",
40
+ "vitest": "^3.2.4",
41
+ "zod": "^4.3.6"
42
+ },
43
+ "packageManager": "pnpm@10.30.2"
44
+ }
package/src/config.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { ConfigError } from './errors.js'
2
+ import type { HoconValue } from './value.js'
3
+
4
+ export class Config {
5
+ constructor(private readonly root: HoconValue & { kind: 'object' }) {}
6
+
7
+ get(path: string): unknown {
8
+ const v = this.lookupNode(path)
9
+ if (v === undefined) return undefined
10
+ return hoconToJs(v)
11
+ }
12
+
13
+ getString(path: string): string {
14
+ const v = this.requireScalar(path)
15
+ if (typeof v !== 'string') throw new ConfigError(`expected string at ${path}, got ${typeof v}`, path)
16
+ return v
17
+ }
18
+
19
+ getNumber(path: string): number {
20
+ const v = this.requireScalar(path)
21
+ if (typeof v !== 'number') throw new ConfigError(`expected number at ${path}, got ${typeof v}`, path)
22
+ return v
23
+ }
24
+
25
+ getBoolean(path: string): boolean {
26
+ const v = this.requireScalar(path)
27
+ if (typeof v !== 'boolean') throw new ConfigError(`expected boolean at ${path}, got ${typeof v}`, path)
28
+ return v
29
+ }
30
+
31
+ getConfig(path: string): Config {
32
+ const v = this.lookupNode(path)
33
+ if (v === undefined) throw new ConfigError(`path not found: ${path}`, path)
34
+ if (v.kind !== 'object') throw new ConfigError(`expected object at ${path}`, path)
35
+ return new Config(v)
36
+ }
37
+
38
+ getList(path: string): unknown[] {
39
+ const v = this.lookupNode(path)
40
+ if (v === undefined) throw new ConfigError(`path not found: ${path}`, path)
41
+ if (v.kind !== 'array') throw new ConfigError(`expected array at ${path}`, path)
42
+ return v.items.map(hoconToJs)
43
+ }
44
+
45
+ has(path: string): boolean {
46
+ return this.lookupNode(path) !== undefined
47
+ }
48
+
49
+ keys(): string[] {
50
+ return [...this.root.fields.keys()]
51
+ }
52
+
53
+ withFallback(fallback: Config): Config {
54
+ const merged = deepMergeHocon(this.root, fallback.root)
55
+ return new Config(merged)
56
+ }
57
+
58
+ toObject(): unknown {
59
+ return hoconToJs(this.root)
60
+ }
61
+
62
+ private lookupNode(path: string): HoconValue | undefined {
63
+ const segments = path.split('.')
64
+ let current: HoconValue = this.root
65
+ for (const seg of segments) {
66
+ if (current.kind !== 'object') return undefined
67
+ const next = current.fields.get(seg)
68
+ if (next === undefined) return undefined
69
+ current = next
70
+ }
71
+ return current
72
+ }
73
+
74
+ private requireScalar(path: string): string | number | boolean | null {
75
+ const v = this.lookupNode(path)
76
+ if (v === undefined) throw new ConfigError(`path not found: ${path}`, path)
77
+ if (v.kind !== 'scalar') throw new ConfigError(`expected scalar at ${path}, got ${v.kind}`, path)
78
+ return v.value
79
+ }
80
+ }
81
+
82
+ function hoconToJs(v: HoconValue): unknown {
83
+ switch (v.kind) {
84
+ case 'scalar': return v.value
85
+ case 'array': return v.items.map(hoconToJs)
86
+ case 'object': {
87
+ const obj: Record<string, unknown> = {}
88
+ for (const [k, val] of v.fields) obj[k] = hoconToJs(val)
89
+ return obj
90
+ }
91
+ }
92
+ }
93
+
94
+ function deepMergeHocon(
95
+ receiver: HoconValue & { kind: 'object' },
96
+ fallback: HoconValue & { kind: 'object' },
97
+ ): HoconValue & { kind: 'object' } {
98
+ const merged = new Map(fallback.fields)
99
+ for (const [k, v] of receiver.fields) {
100
+ const fb = merged.get(k)
101
+ if (fb?.kind === 'object' && v.kind === 'object') {
102
+ merged.set(k, deepMergeHocon(v, fb))
103
+ } else {
104
+ merged.set(k, v)
105
+ }
106
+ }
107
+ return { kind: 'object', fields: merged }
108
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,34 @@
1
+ export class ParseError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public readonly line: number,
5
+ public readonly col: number,
6
+ public readonly file?: string,
7
+ ) {
8
+ super(message)
9
+ this.name = 'ParseError'
10
+ }
11
+ }
12
+
13
+ export class ResolveError extends Error {
14
+ constructor(
15
+ message: string,
16
+ public readonly path: string,
17
+ public readonly line: number,
18
+ public readonly col: number,
19
+ public readonly file?: string,
20
+ ) {
21
+ super(message)
22
+ this.name = 'ResolveError'
23
+ }
24
+ }
25
+
26
+ export class ConfigError extends Error {
27
+ constructor(
28
+ message: string,
29
+ public readonly path: string,
30
+ ) {
31
+ super(message)
32
+ this.name = 'ConfigError'
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { Config } from './config.js'
2
+ export { ConfigError, ParseError, ResolveError } from './errors.js'
3
+ export type { HoconValue } from './value.js'
4
+ export { parse, parseAsync, parseFile, parseFileAsync } from './parse.js'
5
+ export type { ParseOptions } from './parse.js'